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 .pyi header (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:

  1. No variadics — return the method’s own parameters unchanged.

  2. @classmethod cls() detection — if the method is a @classmethod and its AST contains a call cls(..., **kwargs), the **kwargs is resolved against cls.__init__ (which is itself fully resolved). Parameters explicitly passed in the cls(...) call are excluded from the stub.

  3. MRO walk — iterate the class MRO, collecting concrete parameters from each ancestor that defines the same method, until no unresolved **kwargs or *args remain.

    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 annotations

  • from typing import ... (only the names actually used)

  • Type-module imports for aliases that were referenced

  • Cross-file class imports for base classes / annotation types