Skip to content

← Structural Typing and the Hard Parts step 24 of 24

Hard Framework

Writing a minimal .pyi for an untyped dependency

One untyped dependency at an architectural seam turns every value crossing it into Any. --strict will not warn you, because the annotations on your side are all present — they are just annotating values the checker has given up on.

The escalation, cheapest first

  1. types-<pkg> from typeshed, if it exists.
  2. mypy --install-types.
  3. Write your own stub — bootstrap with stubgen -p pkg, then delete everything you do not use.
  4. follow_untyped_imports — infer from the untyped source. Better than nothing, worse than a stub.
  5. Per-module ignore_missing_imports. Never global: the mypy docs describe the global form as “equivalent to adding a # type: ignore to all unresolved imports”.

Step 3 is the one worth being good at, because a stub covering the twelve symbols you actually use is an afternoon’s work and permanently better than the alternatives.

Stub rules that a reviewer will check

  • Function bodies are ... on the same line as the def.
  • No docstrings. A stub is not documentation.
  • No from __future__ import annotations; stubs are never executed.
  • No quoted forward references — stubs are lazily evaluated by definition.
  • Include all @overload variants and never the implementation.
  • Use _typeshed.Incomplete, not Any, for a symbol you have not typed yet. It reads as a to-do and greps cleanly.
  • Declare __all__ if and only if the runtime module has one.

Your task

Emit the stub, minimally.

def used_names(consumer_source: str, module: str) -> set[str]
def solve(consumer_source: str, module: str,
          signatures: dict[str, list[str]]) -> list[str]

used_names walks the consumer’s AST and returns exactly the attributes of module it touches: names from from <module> import a, b (an aliased import a as x still uses a; a bare * is ignored), plus every <alias>.attr where <alias> came from import <module> or import <module> as <alias>.

solve returns the stub as a list of lines. For each used name in sorted order:

  • signatures[name] has one entry → f"def {name}{variant}: ..."
  • signatures[name] has more than one"@overload" then the def line, for each variant, in the given order (overload order is semantic — do not sort it)
  • the name is absent from signatures, or maps to an empty list → f"{name}: Incomplete"

Prepend a header: "from _typeshed import Incomplete" if any Incomplete was emitted, then "from typing import overload" if any overload was, then a single "" separator line — but only if the body is non-empty.

A signature string is the text following the name, e.g. "(path: str) -> list[list[str]]" or "(sink: SupportsWrite, rows: list[list[str]]) -> int". Emit it verbatim: a stub’s parameter may perfectly well be a Protocol, and that is exactly how you type a duck-typed third-party argument you do not control.

The habit

Do not stub the whole library. Stub the seam. The stub is a contract you are asserting about someone else’s code, and every symbol in it is a claim you will have to keep true across their releases — so keep the claim small, and let Incomplete mark the parts you have not verified.

Loading visualization…