How it works¶
stubpy is a pipeline of six focused stages. Each stage is a separate
module with a single responsibility, and all mutable run-state is held in
a StubContext that is created fresh for every
call to generate_stub().
generate_stub(filepath)
│
├─ 1. loader load_module() → module, path
├─ 2. imports scan_import_statements() → import_map
├─ 3. aliases build_alias_registry() → ctx populated
├─ 4. generator collect_classes() → sorted class list
│ └─ for each class:
│ emitter generate_class_stub()
│ └─ for each method:
│ resolver resolve_params()
│ emitter generate_method_stub()
└─ 5. generator assemble header + body → write .pyi
Stage 1 — Module loading¶
stubpy.loader uses importlib.util.spec_from_file_location()
to load the source file as a live Python module. The file’s parent
directory and its grandparent are temporarily added to sys.path
so that package-relative imports inside the target file resolve correctly,
then removed once loading completes.
Stage 2 — Import scanning¶
stubpy.imports parses the source AST to build a
{local_name: import_statement} map of every import in the file. This
map is used later to:
discover type-alias sub-modules (Stage 3),
re-emit cross-file class imports in the
.pyiheader (Stage 5).
Stage 3 — Alias registry¶
stubpy.aliases scans the loaded module for imported sub-modules
(e.g. from demo import types). Any attribute of such a sub-module
that is a type alias — a PEP 604 union (str | int) or a subscripted
typing generic (List[str], Literal["a"]) — is registered in the
StubContext.
When annotation_to_str() encounters a matching
annotation later, it emits types.Length instead of expanding it to
str | float | int.
Stage 4 — Parameter resolution¶
stubpy.resolver implements the core kwargs/args backtracing logic
via resolve_params(). Three strategies are tried in
order:
No variadics — return the method’s own parameters unchanged.
@classmethod cls() detection — if the method is a
@classmethodand its AST contains a callcls(..., **kwargs), the**kwargsis resolved againstcls.__init__(which is itself fully resolved). Parameters explicitly passed in thecls(...)call are excluded from the stub.MRO walk — iterate the class MRO, collecting concrete parameters from each ancestor that defines the same method, until no unresolved
**kwargsor*argsremain.Typed
*args(e.g.*elements: Element) always survive because they carry explicit annotation information that should not be discarded.
Stage 5 — Annotation conversion¶
stubpy.annotations converts live annotation objects to stub-safe
strings using a dispatch table rather than an if/elif chain. Each
annotation kind is handled by a small function decorated with
@_register(predicate). The alias registry is checked first, so
type-module aliases take priority over raw expansion.
Stage 6 — Emission and header assembly¶
stubpy.emitter formats each class and method into .pyi text.
Methods with ≤ 2 non-self parameters stay on one line; longer signatures
are split across lines with a trailing comma on each parameter for clean
diffs.
stubpy.generator then assembles the file header:
from __future__ import annotationsfrom typing import ...(only the names actually used)Type-module imports for aliases that were referenced
Cross-file class imports for base classes / annotation types