Skip to content

← Interfaces with Protocols and ABCs step 22 of 26

Hard Framework

Write a Minimal Stub for an Untyped Package

Your code calls fastcsv, a package that ships no type hints and has no stub package. Here is consumer.py:

from fastcsv import read_rows


def load(path: str) -> list[list[str]]:
    return read_rows(path)

To mypy, every value from fastcsv is Any, a type that turns checking off. So strict mode reports:

Returning Any from function declared to return "list[list[str]]"  [no-any-return]

The fix is a stub file, a .pyi file that holds only type information for a module, with ... in place of function bodies. Save this as stubs/fastcsv.pyi:

def read_rows(path: str) -> list[list[str]]: ...

Then point mypy at the folder:

MYPYPATH=stubs python -m mypy --strict consumer.py

mypy now reads the stub instead of the package, and knows only the names the stub declares. So a stub needs every name your code uses, and each signature should match what the package does at runtime. Before you write one, look for a published stub package, often named types- plus the package name.

What to write

Write a tool that drafts a stub with exactly the names a consumer file uses. You supply the signatures. The tool picks the names and formats the lines.

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

module is an absolute module name such as fastcsv or vendor.fastcsv. Parse the source with ast and never run it. Let a SyntaxError propagate.

Which names to collect

  • From from fastcsv import a, b as c, collect a and b, used or not. Skip * and relative imports.
  • After import fastcsv as fc, collect x from every fc.x. After import vendor.fastcsv with no as, collect x from every vendor.fastcsv.x.
  • Collect only the first attribute after the module, so fc.Reader.parse gives Reader. Reads, assignments and del all count.
  • Match the module name exactly. import vendor or import vendor.fastcsv.sub does not match vendor.fastcsv.
  • Find every import first, then look for attributes. Search the whole file, including nested functions and code that never runs. Ignore scopes, so a local variable named fc still matches.
  • Return each name once.

What solve returns

For each collected name, in sorted order:

signatures[name] Lines
One signature def NAME, the signature, then : ...
Two or more @overload and then the def line, for each signature in the given order
Missing or empty NAME: Incomplete

A signature is everything after the name, such as (path: str) -> list[list[str]]. Copy it exactly. Keep repeated signatures, and add no imports for the types inside them.

Incomplete, from _typeshed, is an alias for Any that marks a type you still have to work out. _typeshed exists only for type checkers, so import it only in a .pyi file.

Start with the header imports the body needs. from _typeshed import Incomplete comes first, then from typing import overload. Follow them with one empty string, then the body. With no header, start with the body. With no collected names, return []. Leave signatures unchanged.

Example

import fastcsv as fc
from fastcsv import Dialect


def go(path: str) -> None:
    fc.write(path, [])
    fc.sniff(path)

With these signatures:

signatures = {
    "write": [
        "(sink: str, rows: list[list[str]]) -> None",
        "(sink: int, rows: list[list[str]]) -> int",
    ],
    "sniff": ["(sample: str) -> str"],
}

solve returns:

[
    "from _typeshed import Incomplete",
    "from typing import overload",
    "",
    "Dialect: Incomplete",
    "def sniff(sample: str) -> str: ...",
    "@overload",
    "def write(sink: str, rows: list[list[str]]) -> None: ...",
    "@overload",
    "def write(sink: int, rows: list[list[str]]) -> int: ...",
]

mypy also ships stubgen, which drafts a stub from a package, and stubtest, which compares a stub with the installed package. See mypy's guide to missing imports.