Skip to content
v0.1.0latest release21 skills2 categories3 sub-categories22 tested scripts68 reference docsContact us

Migration Strategy: Typing an Existing Codebase

Reference for python-type-hints-annotator · 318 lines

Programming / Pythonv1.0.0Updated OCT 20 20265 references1 script

How to take an untyped or partially typed Python codebase (an LLM service, a RAG repo, a research training package) to strict type checking without a big-bang rewrite, and how to modernise old typing syntax safely.

Contents

Principles

  1. Ratchet, never big bang. Coverage and strictness only go up; CI blocks regressions, not legacy.
  2. Types follow data. Typing the central data structures (messages, chunks, configs, requests) gives more value than annotating 200 helpers with dict[str, Any].
  3. Boundaries first, then inward. Validate untyped input once (pydantic, isinstance), then everything behind the boundary can be precise.
  4. Every error is either a bug or a missing type. Fix bugs; model the missing type. Suppress only with an error code and a reason.
  5. The checker is part of the test suite. It runs on every PR with a pinned version.

Phase 0: baseline

bash
# coverage snapshot and the list of untyped functions
python annotation_audit.py src --target-version 3.10 --json > typing-baseline.json
python annotation_audit.py src --target-version 3.10 | tail -3

# how many checker errors at each level?
mypy src --ignore-missing-imports | tail -1
mypy src --strict --ignore-missing-imports | tail -1
pyright src --outputjson | python -c "import json,sys; print(json.load(sys.stdin)['summary'])"

Record: overall coverage %, number of errors in default mode, number in strict mode, the most common error codes (mypy ... | grep -o '\[[a-z-]*\]' | sort | uniq -c | sort -rn). This tells you whether you have a "missing annotations" problem or a "real bugs" problem.

Phase 1: checker on, low strictness

Goal: CI runs a checker and is green, catching new bugs in existing code without demanding annotations.

toml
[tool.mypy]
python_version = "3.10"
files = ["src"]
check_untyped_defs = true          # checks bodies of untyped functions too
warn_unused_ignores = true
warn_redundant_casts = true
no_implicit_optional = true         # default since mypy 0.990
ignore_missing_imports = false

[[tool.mypy.overrides]]
module = ["faiss.*", "some_untyped_sdk.*"]
ignore_missing_imports = true

Fix the errors that appear. In AI codebases, typical real bugs found here: missing await on client calls, None returned from a retrieval helper on empty results and then indexed, wrong argument order in embedding calls, string vs list confusion (stop="\n" vs stop=["\n"]), and dicts with typo'd keys.

If the error count is too large to fix at once: pyright/basedpyright baselines or mypy's per-module ignore_errors = true for the worst modules, each with a ticket to remove it. mypy has no built-in baseline; tools such as mypy-baseline can store and diff a baseline.

Phase 2: type the core data

Find the dicts that flow everywhere and give them names:

Untyped todayBecomes
messages: list[dict]list[ChatMessage] (TypedDict with Literal roles)
doc = {"id":..., "text":..., "meta":...}@dataclass(frozen=True, slots=True) class Document
config = yaml.safe_load(...) passed aroundpydantic Settings / ModelConfig validated once at startup
results: list[tuple] from searchlist[Hit] dataclass or NamedTuple
tool = {"name":..., "parameters":...}ToolSpec TypedDict, or a pydantic model producing the schema
response.json() everywherea pydantic response model per endpoint

Do this in a types.py / schemas.py module with no heavy imports so every module can import it without cycles.

Phase 3: annotate outward from the leaves

Order of work inside a package:

  1. Pure utilities (text cleaning, chunking, scoring, token counting): easy, high reuse.
  2. Adapters around external services (LLM client, vector DB, object storage): define a Protocol for each and annotate the adapter; this is where Any from SDKs is contained.
  3. Core domain services (retriever, agent loop, evaluator) that depend on 1 and 2.
  4. Entry points (FastAPI routes, CLI commands, jobs).

For each module:

bash
python annotation_audit.py src/app/chunking.py --target-version 3.10   # what is missing + suggestions
# annotate, then:
mypy src/app/chunking.py --strict
python annotation_audit.py src/app/chunking.py --min-coverage 100

Then add the module to the strict list (Phase 4) immediately, so it cannot regress.

Phase 4: strict per package

strict = true is a global-only option in mypy configs: per-module overrides must list the individual flags. A practical per-module strict set for modules that are done:

toml
[[tool.mypy.overrides]]
module = ["app.schemas", "app.chunking", "app.retrieval.*", "app.clients.*"]
disallow_untyped_defs = true
disallow_incomplete_defs = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_any_generics = true
warn_return_any = true

Or invert it once most code is typed: global strict = true, plus an override that relaxes the remaining legacy modules. pyright: strict = ["src/app/schemas.py", "src/app/retrieval"].

CI gate that only ratchets up:

bash
python annotation_audit.py src --min-coverage "$(cat .typing-coverage)"
# after improving coverage, bump the number in .typing-coverage in the same PR

Phase 5: ship types

For libraries (internal SDKs, shared RAG components):

  1. pyright --verifytypes yourpkg --ignoreexternal until the public API is complete.
  2. Add an empty src/yourpkg/py.typed and make sure the build includes it.
  3. Export public names explicitly (__all__ or from .x import Name as Name) so no_implicit_reexport users can import them.
  4. Run python -m mypy.stubtest if you also ship .pyi stubs.
  5. Note in the changelog that type changes are API changes: narrowing a parameter type or widening a return type breaks downstream type checks.

Ordering heuristics

Prefer annotating firstBecause
Functions with many callerseach annotation checks every call site
Public API and module boundariesdocuments contracts, catches misuse
Functions returning None sometimesthe source of most runtime AttributeErrors
Code that handles external data (HTTP, LLM output, files)where wrong assumptions live
Async codemissing await is invisible without types
Recently changed / buggy moduleshighest return on effort

Leave for last: one-off scripts, notebooks, tests (annotate fixtures and helpers; test functions can stay -> None only).

Third-party libraries without types

StepAction
1Check for inline types (newer version may have py.typed) or types-<pkg> on PyPI
2Check typeshed's list of stubs and community stubs (pandas-stubs, types-tensorflow, boto3-stubs)
3Wrap the library in a small typed adapter module; only that module imports it
4Write a minimal .pyi stub for the handful of functions you use (stubs/<pkg>/__init__.pyi)
5Last resort: per-module ignore_missing_imports = true; values from it are Any, so annotate at the adapter

Modernising legacy syntax

Do syntax modernisation as its own mechanical PR, separate from annotation work and behaviour changes, so review is trivial.

bash
# report the rewrites for your minimum version
python annotation_audit.py src --target-version 3.10 --ignore missing-param,missing-return

# autofix with ruff (set target-version = "py310" in [tool.ruff])
ruff check src --select UP006,UP007,UP035,UP037,UP045 --fix
# 3.12+: PEP 695 generics and the type statement (some are unsafe fixes)
ruff check src --select UP040,UP046,UP047 --fix --unsafe-fixes
RuleRewrite
UP006List[int] → list[int]
UP007 / UP045Union[A, B] → A | B, Optional[X] → X | None
UP035deprecated typing imports → collections.abc etc.
UP037remove quotes from annotations when safe (with deferred annotations)
UP040X: TypeAlias = ... → type X = ...
UP046 / UP047Generic[T] classes / functions with TypeVars → PEP 695

Safety rules for each rewrite:

RewriteSafe whenWatch out for
List → listtarget ≥ 3.9, or inside annotations with the future importruntime aliases and cast() on 3.8
Optional[X] → X | Nonetarget ≥ 3.10, or only inside annotations with the future import / 3.14pydantic models, FastAPI params, dataclass fields read by get_type_hints, TypeVar(bound=...), aliases: all evaluated at runtime
"Foo" | None (forward ref)annotations deferredat runtime str | None raises TypeError; quote the whole thing: "Foo | None"
typing.Callable → collections.abc.Callable3.10+ (3.9.0/3.9.1 had bugs with Callable[..., T] and ParamSpec at runtime)
TypeAlias → typetarget ≥ 3.12a type alias is not a class: isinstance(x, Alias) and calling it break; pydantic supports type aliases in v2.x but check your version
TypeVar → PEP 695target ≥ 3.12module-level TypeVars shared across many functions become per-function parameters; that is intended
typing_extensions.X → typing.Xtarget ≥ the version in the cheatsheettyping_extensions.TypedDict/TypeVar also carry newer features (closed TypedDicts, defaults)

After the rewrite: run the full test suite (runtime annotation consumers fail at import time, not in the checker), then the checker.

Raising the minimum Python version

Typing improvements are a good reason to raise the floor. When moving from 3.9:

New floorUnlocks
3.10X | None everywhere including pydantic/FastAPI, ParamSpec, TypeAlias, match for narrowing, slots=True dataclasses
3.11Self, Never, assert_never, Required/NotRequired, StrEnum, TaskGroup, ExceptionGroup
3.12type statement, PEP 695 generics, @override, Unpack[TypedDict] for **kwargs
3.13TypeIs, ReadOnly, type parameter defaults, warnings.deprecated

Update together: requires-python in pyproject.toml, [tool.mypy] python_version, [tool.pyright] pythonVersion, [tool.ruff] target-version, the CI matrix, and then run the modernisation pass.

Automated annotation tools

ToolWhat it doesCaveat
annotation_audit.py (this skill)coverage, missing annotations, safe literal-based suggestions, legacy syntax rewritessuggestions only; never edits
MonkeyTyperecords runtime types while tests run, then applies annotationstypes are as narrow as what tests exercised; review every one
pyannotatesimilar runtime collection (older, less maintained)
pyright --createstub, stubgenstubs for third-party packagesgenerated stubs are mostly Any/Incomplete; refine by hand
autotypingadds trivially inferable annotations (-> None, -> bool for literals)limited scope, very safe
ruff ANN rulesrequire annotationsonly enforcement
ruff UP rulesmodernise syntaxneeds a correct target-version
LLM assistancedrafts annotations for a moduleverify with the checker; LLMs over-use Any and dict

Annotating a function: worked example

Before:

python
def retrieve(query, store, embedder, k=5, filters=None, min_score=0.0):
    vec = embedder.embed([query])[0]
    hits = store.search(vec, k=k * 2, where=filters)
    good = [h for h in hits if h.score >= min_score]
    if not good:
        return None
    return good[:k]

Audit output (abridged):

text
L1    retrieve  [missing: query, store, embedder, k, filters, min_score, return]
         k              -> k: int = 5
         filters        -> filters: <T> | None = None
         min_score      -> min_score: float = 0.0
         return            (return value(s) not inferable from literals)

After, reasoning one parameter at a time:

python
from collections.abc import Mapping

def retrieve(
    query: str,
    store: VectorStore[Chunk],                       # Protocol: any backend works, fakes in tests
    embedder: Embedder,
    k: int = 5,
    filters: Mapping[str, str | int | float | bool] | None = None,   # read-only: Mapping
    min_score: float = 0.0,
) -> list[Hit[Chunk]]:                               # concrete return
    vec = embedder.embed([query])[0]
    hits = store.search(vec, k=k * 2, where=filters)
    return [h for h in hits if h.score >= min_score][:k]

The annotation exposed a design smell: returning None for "no results" forced every caller to handle None; an empty list is the natural empty value, so the return type became list[Hit[Chunk]] and the None branch disappeared.

Team conventions

Write these down in CONTRIBUTING or the repo's AGENTS/CLAUDE file:

  • Minimum Python version and therefore the allowed syntax (X | None, list[...], type statement or not).
  • from __future__ import annotations: everywhere, nowhere, or only in non-pydantic modules. Pick one.
  • Where shared types live (app/types.py), naming (ChatMessage, ToolSpec, *Config).
  • When to use TypedDict vs dataclass vs pydantic (see ai-ml-typing.md).
  • Any policy: allowed only in adapters around untyped libraries, with a comment.
  • Suppression policy: # type: ignore[code] # reason, no bare ignores, warn_unused_ignores on.
  • New code must be fully annotated; touched functions get annotated in the same PR.

Anti-patterns

Anti-patternWhy it hurtsInstead
dict[str, Any] everywhereno key or value checking, false sense of safetyTypedDict / dataclass / pydantic
Annotating to match the buge.g. -> str | None because a helper sometimes forgot to returnfix the function
cast() to silence errorslies to the checkernarrow, or fix the type upstream
Global ignore_errors = truechecker runs, checks nothingper-module, with a ticket
Optional on every parameter "to be safe"forces None handling everywhereonly where None is meaningful
list[T] parametersinvariance errors for callers, implies mutationSequence[T] / Iterable[T]
Returning Iterablecallers can't len() or indexreturn list[T]
Unpinned checker in CIrandom red buildspin and upgrade deliberately
Typing tests exhaustively firstlow valuetype the code under test first
TYPE_CHECKING imports for pydantic field typesNameError / PydanticUndefinedAnnotation at runtimereal imports for runtime-evaluated annotations
More in Python

python-beginner-concept-tutor

Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.

python-comprehension-refactorer

Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.

python-context-manager-writer

Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.

python-dataclass-converter

Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.

python-exception-handling-designer

Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks.

python-idiomatic-code-reviewer

Review Python code for non-idiomatic, unpythonic or risky patterns and rewrite it into clean modern Python: PEP 8 / PEP 20, EAFP vs LBYL, iteration idioms (enumerate, zip, comprehensions, any/all), unpacking, f-strings, pathlib, truthiness, `is None`, mutable defaults, walrus, match statements, collections / itertools / functools, logging instead of print, with-blocks and resource handling. Includes AI-engineering checks: hardcoded API keys, time.sleep or requests inside async def, missing HTTP timeouts, unbounded retry loops, model clients built at import time, prompt string concatenation, sequential awaits, eval() on LLM output. Ships a 46-rule AST reviewer with severities and suggested rewrites.