Skip to content

feat: --stubs, emitting client.pyi for editors that cannot follow bind_method - #8

Merged
goduni merged 4 commits into
mainfrom
feat/stubs
Aug 7, 2026
Merged

feat: --stubs, emitting client.pyi for editors that cannot follow bind_method#8
goduni merged 4 commits into
mainfrom
feat/stubs

Conversation

@goduni

@goduni goduni commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Why

A declarative client binds each operation through bind_method:

class FrankfurterAPIClient(HTTPXSyncClient):
    get_rates_for_date = bind_method(GetRatesForDate)

bind_method returns a descriptor whose __get__ overloads carry a ParamSpec taken
from the request dataclass. mypy and pyright resolve that; PyCharm does not, so it
shows no signature, no parameter info and no return type for any operation. That is an
IDE limitation, not something the generated code can work around at runtime.

Until now the only way out was --style imperative, which changes the runtime code:
an extra call frame per request and a client module that grows with the spec.

What

--stubs writes a client.pyi next to client.py. The runtime module is unchanged —
it still binds declaratively — while type checkers and IDEs read the stub, which spells
every operation out, docstrings included:

class FrankfurterAPIClient(HTTPXSyncClient):
    def __init__(self, base_url: str = DEFAULT_BASE_URL, *, session: Any = None,
                 middleware: list[Any] | None = None) -> None: ...
    def get_rates_for_date(
        self, *, date: str,
        base: Omittable[str] = Omitted(),
        symbols: Omittable[list[str]] = Omitted(),
        amount: Omittable[float] = Omitted(),
    ) -> ExchangeRates:
        """Historical exchange rates for a date

        Reference rates for a specific day (YYYY-MM-DD)...
        """

Off by default; stubs = true also works in a config file.

Decisions

Only client.py is stubbed. Everything else already resolves in PyCharm on its own:
request classes and adaptix models are plain dataclasses, Pydantic has a bundled plugin,
and msgspec.Struct carries @dataclass_transform (PEP 681) in msgspec's own stubs.
Every extra stub would also be one more module type checkers read instead of the
implementation.

Signatures come from one place. method_signature() is extracted from the imperative
client renderer and shared with the stub renderer, so the two cannot drift. Flat clients
reuse the same de-duplicated attribute names via flat_client_attributes() — a stub
attribute named differently than the runtime one simply would not exist.

--stubs with --style imperative is rejected. Imperative already spells the same
signatures out in client.py; two copies could only drift apart.

--check runs mypy twice when stubs are on. A .pyi takes its module out of mypy's
sight completely — measured: mypy reports "3 source files" for a 4-file package and
misses a stub/implementation mismatch. The second pass excludes the stub, so client.py
(middleware assembly, auth wiring, sub-client construction) stays checked.

Two bugs found while reviewing this, fixed here

A parameter named self. It is legal in a spec, and the request dataclass takes it
as a field — dataclasses renames its own receiver to __dataclass_self__ for exactly
this case, so GetA(self=...) constructs fine and a declarative client calls it fine
today. Any renderer that spells the signature out has to do the same, or it emits
def get_a(self, *, self: str): a duplicate-argument syntax error that fails ruff and
aborts the whole generation. --style imperative has had this bug all along; --stubs
would have extended it to declarative clients. method_receiver() now moves the receiver
aside for both.

A stale client.pyi. Generation writes over an existing package rather than clearing
it, so turning --stubs back off used to leave the stub behind — where it keeps
overriding client.py for every type checker and IDE. Once the spec has moved on that is
worse than no stub at all: the types are silently wrong rather than merely absent. The
stub is now removed when stubs are off.

Verification

  • Generated output is byte-identical to main across 24 configurations (3 specs ×
    declarative/imperative × flat/grouped × single/per-object) — the shared-helper
    extraction changed nothing.
  • --stubs --check is green across all three serializers and every --layout ×
    --file-layout combination.
  • The second mypy pass was verified to catch an error injected into client.py that the
    first pass does not see.
  • A wheel built from a stubbed package contains client.pyi and py.typed.
  • 14 edge cases checked in isolation, baseline vs stubs: Python keywords as parameter
    names, mutable defaults, """/backslashes/trailing quote in docstrings, multipart
    upload, non-object JSON body, deepObject, basic auth, enum returns, 12-parameter
    signatures, specs without servers, a tag named client, a spec with no operations.
  • 322 tests, 100% coverage, ruff and mypy --strict clean.

Known, out of scope

operationId: callMethod breaks --check — in a flat client it collides with the
inherited call_method, in a grouped one it redefines the sub-client's. Reproduces
identically on main, with or without --stubs; untouched here.

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (5e84caa) to head (62da804).

Additional details and impacted files
@@            Coverage Diff             @@
##              main        #8    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           30        31     +1     
  Lines         2604      2706   +102     
==========================================
+ Hits          2604      2706   +102     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

goduni added 4 commits August 7, 2026 18:05
…d_method

A declarative client binds each operation through bind_method, whose __get__
overloads carry a ParamSpec taken from the request dataclass. mypy and pyright
resolve that; PyCharm does not, so it shows no signature, no parameter info and
no return type for any operation. Until now the only way out was --style
imperative, which changes the runtime code.

--stubs writes a client.pyi next to client.py: the runtime module keeps binding
declaratively, while type checkers and IDEs read a stub that spells every
operation out, docstrings included. Only client.py is stubbed -- models and
request classes are dataclasses, Pydantic models or msgspec Structs, which
PyCharm already resolves, and every extra stub is a module type checkers would
read instead of the implementation.

Signatures come from a new shared method_signature() helper, extracted from the
imperative client renderer, so the two renderings cannot drift; flat clients
reuse the same de-duplicated attribute names through flat_client_attributes().

Combining --stubs with --style imperative is rejected: imperative already
spells the same signatures out. Under --check, a stub takes client.py out of
mypy's sight entirely, so --stubs adds a second mypy pass with the stub
excluded -- without it the runtime module would ship unchecked.
…'self'

'self' is a legal parameter name in a spec, and the request dataclass carries it
as a field -- dataclasses renames its own receiver to __dataclass_self__ for
exactly this case, so GetA(self=...) constructs fine and a declarative client
calls it fine today.

Any renderer that spells the signature out has to do the same, or it emits
'def get_a(self, *, self: str)': a duplicate-argument syntax error that fails
ruff and aborts the whole generation. --style imperative has had that bug all
along; --stubs would have inherited it and extended it to declarative clients,
which generate cleanly today.

method_receiver() picks the first free name from self, self_, self__, and both
the shared signature builder and the imperative delegating body use it.
Generation writes over an existing package rather than clearing it. A stub
overrides client.py for every type checker and IDE that reads it, so one left
behind by an earlier --stubs run keeps serving that run's signatures. Once the
spec has moved on that is worse than having no stub at all: the types are
silently wrong rather than merely absent, and nothing in the output hints at
why.
@goduni
goduni merged commit e006d27 into main Aug 7, 2026
5 checks passed
@goduni
goduni deleted the feat/stubs branch August 7, 2026 18:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant