feat: --stubs, emitting client.pyi for editors that cannot follow bind_method - #8
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A declarative client binds each operation through
bind_method:bind_methodreturns a descriptor whose__get__overloads carry aParamSpectakenfrom 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
--stubswrites aclient.pyinext toclient.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:
Off by default;
stubs = truealso works in a config file.Decisions
Only
client.pyis 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.Structcarries@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 imperativeclient 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 stubattribute named differently than the runtime one simply would not exist.
--stubswith--style imperativeis rejected. Imperative already spells the samesignatures out in
client.py; two copies could only drift apart.--checkruns mypy twice when stubs are on. A.pyitakes its module out of mypy'ssight 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 itas a field —
dataclassesrenames its own receiver to__dataclass_self__for exactlythis case, so
GetA(self=...)constructs fine and a declarative client calls it finetoday. 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 andaborts the whole generation.
--style imperativehas had this bug all along;--stubswould have extended it to declarative clients.
method_receiver()now moves the receiveraside for both.
A stale
client.pyi. Generation writes over an existing package rather than clearingit, so turning
--stubsback off used to leave the stub behind — where it keepsoverriding
client.pyfor every type checker and IDE. Once the spec has moved on that isworse than no stub at all: the types are silently wrong rather than merely absent. The
stub is now removed when stubs are off.
Verification
mainacross 24 configurations (3 specs ×declarative/imperative × flat/grouped × single/per-object) — the shared-helper
extraction changed nothing.
--stubs --checkis green across all three serializers and every--layout×--file-layoutcombination.client.pythat thefirst pass does not see.
client.pyiandpy.typed.names, mutable defaults,
"""/backslashes/trailing quote in docstrings, multipartupload, non-object JSON body, deepObject, basic auth, enum returns, 12-parameter
signatures, specs without
servers, a tag namedclient, a spec with no operations.ruffandmypy --strictclean.Known, out of scope
operationId: callMethodbreaks--check— in a flat client it collides with theinherited
call_method, in a grouped one it redefines the sub-client's. Reproducesidentically on
main, with or without--stubs; untouched here.