Generating Stub Files with stubgen

TL;DR — When a dependency ships no inline types and has no types-* package, run stubgen — it comes with mypy — to auto-generate .pyi skeleton files from the module’s structure. The skeletons are full of Any, so you refine the important signatures by hand, then point the checker at the folder with mypy_path / MYPYPATH for mypy or stubPath for pyright.

stubgen reads a package (by import or by parsing source) and writes one .pyi per module containing every public class, function, and attribute — with real names and structure but placeholder Any types. That skeleton is a starting point, not a finished stub: it makes the API visible to the checker, and each signature you refine turns an Any into a real constraint.

stubgen workflow: generate, refine, wire up An untyped package is fed to stubgen, which emits Any-filled pyi skeletons; you refine them, then register the stubs folder with mypy_path or stubPath. untyped dep legacypay/ stubgen .pyi full of Any refine by hand real signatures wire path mypy sees it generate → refine → register
stubgen bootstraps the shape of the API; you supply the real types and register the folder so the checker resolves them.

Generating the skeletons

Suppose legacypay is an installed dependency with zero annotations. Point stubgen at the package name and choose an output directory:

$ stubgen -p legacypay -o stubs
Processed 6 modules
Generated files under stubs/legacypay

This writes stubs/legacypay/__init__.pyi, stubs/legacypay/client.pyi, and so on. You can also target a single file with stubgen path/to/module.py, or add --include-private to emit underscore-prefixed members. For C extensions where source parsing fails, --inspect-mode imports the module and introspects it at runtime instead.

The raw output is deliberately conservative — everything is Any:

# stubs/legacypay/client.pyi  — as emitted by stubgen (mypy 1.10)
from typing import Any

class PaymentClient:
    api_key: Any
    def __init__(self, api_key: Any, timeout: Any = ...) -> None: ...
    def charge(self, amount: Any, currency: Any = ...) -> Any: ...

Refining the stub

The skeleton compiles but constrains nothing — every Any is a hole. Replace the placeholders with the real types you know from the library’s docs or source. This is where the value is created:

# stubs/legacypay/client.pyi  — refined by hand
from decimal import Decimal

class PaymentClient:
    api_key: str
    def __init__(self, api_key: str, timeout: float = ...) -> None: ...
    def charge(self, amount: Decimal, currency: str = ...) -> str: ...
    # returns the transaction id

Keep ... as the default-value placeholder — in a .pyi it stands in for “some default exists,” and its literal value is irrelevant to the checker. You do not have to refine every member at once; each signature you tighten immediately upgrades checking at that call site.

Wiring the stubs into the checker

The stub folder is useless until the checker knows to search it. Register stubs/ as a search root.

mypy, via pyproject.toml:

# pyproject.toml — mypy 1.10+
[tool.mypy]
mypy_path = "stubs"

Or per-invocation with the MYPYPATH environment variable:

$ MYPYPATH=stubs mypy app.py

pyright, via pyrightconfig.json (or the [tool.pyright] table):

{
  "stubPath": "stubs"
}

With the path registered, a previously untyped call now type-checks against your refined signatures:

# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from decimal import Decimal
from legacypay.client import PaymentClient

client = PaymentClient(api_key="sk_live_…")
txn_id: str = client.charge(Decimal("19.99"), currency="usd")

client.charge(19.99)   # mypy: error [arg-type] — float is not Decimal
                       # pyright: reportArgumentType
Runtime vs static analysis Your .pyi files never run and are never imported by Python. If a refined stub claims charge takes a Decimal but the real function silently accepts a float, mypy will flag the float call even though the program executes fine — and conversely a stub that promises a return type the code does not deliver hides a genuine bug. Stubs assert; they do not verify. Keep them honest against the real implementation.

Common mistakes

  • Leaving the skeleton un-refined. A stub that is still all Any silences mypy [import-untyped] but checks nothing — every call resolves to Any. You have hidden the warning without gaining type safety.
  • Forgetting to register the path. Generating stubs/legacypay/*.pyi without setting mypy_path/stubPath leaves mypy reporting [import-untyped] (or pyright reportMissingModuleSource) — the files exist but the checker never looks there.
  • Mismatched package layout. The folder under your stub root must mirror the import path exactly: legacypay.client needs stubs/legacypay/client.pyi. A flattened or misnamed folder yields [import-not-found].
  • Committing stubs for a package that later ships py.typed. Once the upstream library adds inline types, your stale local stubs shadow them and can introduce phantom [attr-defined] errors. Delete local stubs when upstream types arrive.

FAQ

Do I have to refine every signature stubgen produces? No. Refine the parts of the API you actually call; leave the rest as Any. Partial stubs still improve checking at every site you have annotated, and you can tighten more over time.

stubgen vs a published types-* package — which should I use? Always prefer an existing types-* stub package or upstream py.typed types; they are maintained and versioned for you. Reach for stubgen only when no maintained stubs exist, treating your local stubs as a temporary bridge.

Back to Type Stubs and Third-Party Packages