CoursePython · Application Architecture and Production Readiness · part 75 of 79
Part 75 · Application Architecture and Production Readiness

Lesson 5: Type and Quality Gates

3 min read·9 Sept 2026

mypy strict

Module 1 turned on strict. What it actually requires is worth stating now that the codebase is large.

text
[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 = true

strict 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

python
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.

python
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.

yaml
  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.