Lesson 5: Type and Quality Gates
mypy strict
Module 1 turned on strict. What it actually requires is worth stating now that the codebase is large.
[tool.mypy]
python_version = "3.12"
strict = true
warn_unreachable = true
show_error_codes = true
[[tool.mypy.overrides]]
module = ["some_untyped_library.*"]
ignore_missing_imports = truestrict requires every function to be annotated, forbids implicit Any in several positions, and warns on unused ignores. Its practical value in a system like this is that the Protocols from Module 7 become enforced contracts: a provider adapter missing a method fails type checking rather than failing at runtime in the one code path that calls it.
Two rules for keeping it green. Silence per module, never globally, since a global ignore_missing_imports disables checking of your own code too. And treat a # type: ignore as requiring a reason comment, because an unexplained ignore becomes permanent.
Protocol, generics, TypeVar, and overloads
from typing import Protocol, TypeVar
T = TypeVar("T", bound=BaseModel)
class Validator(Protocol[T]):
def validate(self, raw: str) -> T: ...
async def extract_typed(
document: Document, schema: type[T], *, provider: LLMProvider
) -> T:
"""Return an instance of exactly the schema that was passed in."""
response = await provider.generate(build_messages(document, schema))
return schema.model_validate_json(extract_json_text(response.text))
The TypeVar is what makes extract_typed(doc, ExtractedRecipe) return an ExtractedRecipe rather than a BaseModel, so the caller keeps field checking. Without it, everything downstream loses its types at that boundary.
overload describes a function whose return type depends on its arguments.
from typing import Literal, overload
@overload
async def search(query: str, *, rerank: Literal[True]) -> list[RerankedChunk]: ...
@overload
async def search(query: str, *, rerank: Literal[False]) -> list[ScoredChunk]: ...
async def search(query: str, *, rerank: bool = True) -> list[ScoredChunk]:
...
Use it sparingly. Two overloads clarify, five suggest the function should be two functions.
Typing async code has a few shapes worth knowing: Awaitable[T] for something you can await, AsyncIterator[T] for an async generator, and Coroutine[Any, Any, T] for the object an async function returns. Module 4's rule applies unchanged: annotate a generator-returning function as AsyncIterator[T], never as list[T], or callers will iterate it twice.
Ruff and pre-commit in CI
Module 1 set these up locally. The production requirement is that CI runs the same checks, because hooks can be skipped.
fast:
steps:
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy src
- run: uv run pytest -m "not integration and not live and not eval"
--check on the formatter reports differences without rewriting, which is what you want in CI: a failure telling the author to run the formatter, rather than a CI job committing to the branch.
uv sync --locked from Module 1 fails if the lockfile is stale, catching the dependency added without committing the lock.
The pre-commit hooks and CI must run the same rule set, or the two disagree and people stop trusting whichever is stricter.