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.
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
.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
Anysilences mypy[import-untyped]but checks nothing — every call resolves toAny. You have hidden the warning without gaining type safety. - Forgetting to register the path. Generating
stubs/legacypay/*.pyiwithout settingmypy_path/stubPathleaves mypy reporting[import-untyped](or pyrightreportMissingModuleSource) — 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.clientneedsstubs/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.