From 9bad668276cda720143a8c851693c13a75f0186b Mon Sep 17 00:00:00 2001 From: goduni <37146584+goduni@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:38:39 +0000 Subject: [PATCH 1/4] feat: --stubs, emitting client.pyi for editors that cannot follow bind_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. --- README.md | 65 ++++- src/unihttp_openapi_generator/cli.py | 9 + src/unihttp_openapi_generator/config.py | 6 + src/unihttp_openapi_generator/config_file.py | 1 + src/unihttp_openapi_generator/emit.py | 3 + src/unihttp_openapi_generator/pipeline.py | 13 +- .../render/clients.py | 61 ++--- .../render/methods.py | 33 +++ src/unihttp_openapi_generator/render/stubs.py | 148 +++++++++++ tests/test_compile_gate.py | 67 ++++- tests/test_config.py | 15 +- tests/test_config_file.py | 49 ++++ tests/test_emit.py | 46 ++++ tests/test_pipeline_cli_extra.py | 23 +- tests/test_render_stubs.py | 246 ++++++++++++++++++ 15 files changed, 735 insertions(+), 50 deletions(-) create mode 100644 src/unihttp_openapi_generator/render/stubs.py create mode 100644 tests/test_render_stubs.py diff --git a/README.md b/README.md index 64cda53..5c4c327 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ wiring. The output is formatted with `ruff` and type-checks clean under `mypy -- - [Method style — `--style`](#method-style----style) - [Optional fields — `--optional`](#optional-fields----optional) - [Inheritance — `--inheritance`](#inheritance----inheritance) + - [Type stubs — `--stubs`](#type-stubs----stubs) - [OpenAPI coverage](#openapi-coverage) - [Checking the output — `--check`](#checking-the-output----check) - [Limitations](#limitations) @@ -253,6 +254,7 @@ unihttp-openapi-generator generate SPEC [options] | `--optional` | `none` · `omitted` (`none`) — `omitted` distinguishes absent from null (adaptix) | | `--strip-prefix` | `auto` or a dotted prefix to drop from schema names (e.g. `io.k8s.api.core.v1.Pod` → `CoreV1Pod`) | | `--inheritance` | off by default — render `allOf: [$ref]` as a base class instead of merging its fields in | +| `--stubs` | off by default — also emit `client.pyi` so PyCharm sees every method signature ([details](#type-stubs----stubs)) | | `--check` | run `ruff` and `mypy --strict` on the output ([details](#checking-the-output----check)) | | `--config` | TOML config file | @@ -298,6 +300,7 @@ style = "declarative" # declarative | imperative (method style) optional = "none" # none | omitted (optional model fields) strip_prefix = "auto" # "auto" or a dotted prefix to drop from schema names inheritance = false # allOf: [$ref] -> a base class instead of merged fields +stubs = false # also emit client.pyi (see --stubs) check = true # run ruff + mypy --strict on the output ``` @@ -377,6 +380,10 @@ How client methods are written. date=date, page=page, limit=limit)) ``` +If you are reaching for `imperative` only because your editor cannot see through +`bind_method`, [`--stubs`](#type-stubs----stubs) gets you the same signatures without +changing the runtime code. + ### Optional fields — `--optional` How optional model fields are represented (adaptix only). @@ -466,6 +473,60 @@ What to do with `allOf: [{$ref: Base}, ...]`. the tagged decoding can be wired in `_serialization.py`. Leave `--inheritance` off if you want polymorphic responses to parse into subtypes out of the box. +### Type stubs — `--stubs` + +A declarative client binds each operation from its request class: + +```python +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 a +limitation of the IDE, not something the generated code can work around at runtime. + +`--stubs` writes a `client.pyi` next to `client.py`. The runtime module is unchanged — +it still binds declaratively — but type checkers and IDEs read the stub, which spells +every operation out, docstrings included: + +```python +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)... + """ +``` + +Notes: + +- Only `client.py` gets a stub. Models and request classes are plain dataclasses, + Pydantic models, or `msgspec.Struct`s — PyCharm resolves all three on its own, and + every extra stub would be one more module that type checkers read *instead of* the + implementation. +- It cannot be combined with `--style imperative`, which already spells the same + signatures out in `client.py`; the generator rejects the combination rather than + emitting two copies that can drift apart. +- `--check` runs `mypy --strict` twice when stubs are on: once as a consumer sees the + package (the stub wins) and once with the stub excluded, so `client.py` is still + type-checked rather than being silently skipped. + ## OpenAPI coverage - 3.0 and 3.1; JSON or YAML; file or URL; internal and external `$ref`. @@ -479,7 +540,9 @@ What to do with `allOf: [{$ref: Base}, ...]`. ## Checking the output — `--check` -`--check` runs `ruff check` and `mypy --strict` over the generated package. +`--check` runs `ruff check` and `mypy --strict` over the generated package. With +[`--stubs`](#type-stubs----stubs) it runs `mypy` a second time with `client.pyi` +excluded, so the runtime module a stub would otherwise hide stays checked too. Both tools are ordinary dependencies of the generator, so installing it installs them — there is nothing extra to add. They are also resolved from the generator's *own* diff --git a/src/unihttp_openapi_generator/cli.py b/src/unihttp_openapi_generator/cli.py index c809b24..b18c36f 100644 --- a/src/unihttp_openapi_generator/cli.py +++ b/src/unihttp_openapi_generator/cli.py @@ -68,6 +68,14 @@ def generate( help="Render 'allOf: [$ref]' as a base class instead of merging its fields in.", ), ] = None, + stubs: Annotated[ + bool | None, + typer.Option( + "--stubs/--no-stubs", + help="Also emit client.pyi, so editors that cannot follow bind_method " + "(notably PyCharm) still see every method signature.", + ), + ] = None, check: Annotated[bool | None, typer.Option("--check/--no-check")] = None, config: Annotated[ Path | None, @@ -97,6 +105,7 @@ def generate( "file_layout": file_layout, "strip_prefix": strip_prefix, "inheritance": inheritance, + "stubs": stubs, "check": check, } diff --git a/src/unihttp_openapi_generator/config.py b/src/unihttp_openapi_generator/config.py index dd2e1d2..891660a 100644 --- a/src/unihttp_openapi_generator/config.py +++ b/src/unihttp_openapi_generator/config.py @@ -72,6 +72,7 @@ class GeneratorConfig(BaseModel): file_layout: FileLayout = FileLayout.SINGLE strip_prefix: str | None = None # "auto" or a dotted prefix to drop from schema names inheritance: bool = False # allOf: [$ref] -> a real base class instead of merged fields + stubs: bool = False # emit client.pyi so editors see explicit method signatures check: bool = False @model_validator(mode="after") @@ -82,6 +83,11 @@ def _validate(self) -> GeneratorConfig: ) if self.optional is OptionalStyle.OMITTED and self.serializer is not Serializer.ADAPTIX: raise ValueError("--optional omitted is only supported with the adaptix serializer") + if self.stubs and self.style is MethodStyle.IMPERATIVE: + raise ValueError( + "--stubs is redundant with --style imperative, which already spells " + "every method signature out in client.py" + ) return self @property diff --git a/src/unihttp_openapi_generator/config_file.py b/src/unihttp_openapi_generator/config_file.py index f1d965d..14abca0 100644 --- a/src/unihttp_openapi_generator/config_file.py +++ b/src/unihttp_openapi_generator/config_file.py @@ -34,6 +34,7 @@ "file_layout", "strip_prefix", "inheritance", + "stubs", "check", } ) diff --git a/src/unihttp_openapi_generator/emit.py b/src/unihttp_openapi_generator/emit.py index 177959f..726176f 100644 --- a/src/unihttp_openapi_generator/emit.py +++ b/src/unihttp_openapi_generator/emit.py @@ -34,6 +34,7 @@ from unihttp_openapi_generator.render.query import deep_object_query_keys, render_query_module from unihttp_openapi_generator.render.serializers import get_strategy from unihttp_openapi_generator.render.serializers.base import SerializerStrategy +from unihttp_openapi_generator.render.stubs import render_client_stub _BACKEND_DISTRIBUTION = { SyncBackend.HTTPX: "httpx>=0.28.1", @@ -203,6 +204,8 @@ def write_package(doc: IRDocument, config: GeneratorConfig) -> Path: if deep_object_query_keys(doc): _write_py(package_dir / "_query.py", render_query_module()) _write_py(package_dir / "client.py", render_client_module(doc, config, package)) + if config.stubs: + _write_py(package_dir / "client.pyi", render_client_stub(doc, config, package)) _write_py(package_dir / "__init__.py", _render_package_init(doc, config, package)) (package_dir / "py.typed").write_text("") diff --git a/src/unihttp_openapi_generator/pipeline.py b/src/unihttp_openapi_generator/pipeline.py index b6a7656..2335589 100644 --- a/src/unihttp_openapi_generator/pipeline.py +++ b/src/unihttp_openapi_generator/pipeline.py @@ -48,12 +48,21 @@ def _mypy_args() -> list[str]: return args -def _check_package(package_dir: Path) -> None: +def _check_package(package_dir: Path, *, stubs: bool) -> None: # Generated packages ship without a ``[tool.ruff]`` table and are meant to lint # under ruff's defaults; ``--isolated`` ignores any ambient config that ruff # would otherwise discover from the cwd/parent dirs. _run_check("ruff", [ruff_executable(), "check", "--isolated"], package_dir) _run_check("mypy", [*mypy_command(), *_mypy_args()], package_dir) + if stubs: + # ``client.pyi`` makes mypy skip ``client.py`` entirely, so the run above + # checks what a consumer sees and nothing of the module that actually runs. + # Exclude the stub and check the implementation too. + _run_check( + "mypy (implementation)", + [*mypy_command(), *_mypy_args(), "--exclude", r"client\.pyi$"], + package_dir, + ) def run_generation(spec_source: str, config: GeneratorConfig) -> Path: @@ -71,5 +80,5 @@ def run_generation(spec_source: str, config: GeneratorConfig) -> Path: root = write_package(doc, config) logger.info("generated %s client at %s", config.package_name, root) if config.check: - _check_package(root / config.package_name) + _check_package(root / config.package_name, stubs=config.stubs) return root diff --git a/src/unihttp_openapi_generator/render/clients.py b/src/unihttp_openapi_generator/render/clients.py index e891737..e157637 100644 --- a/src/unihttp_openapi_generator/render/clients.py +++ b/src/unihttp_openapi_generator/render/clients.py @@ -21,18 +21,20 @@ from unihttp_openapi_generator.render.engine import render_template from unihttp_openapi_generator.render.imports import render_import_lines from unihttp_openapi_generator.render.methods import ( + method_signature, operation_fields, + signatures_use_omitted, tag_module_name, ) from unihttp_openapi_generator.render.query import deep_object_query_keys -_SYNC_BACKENDS: dict[SyncBackend, tuple[str, str]] = { +SYNC_BACKENDS: dict[SyncBackend, tuple[str, str]] = { SyncBackend.HTTPX: ("HTTPXSyncClient", "unihttp.clients.httpx"), SyncBackend.REQUESTS: ("RequestsSyncClient", "unihttp.clients.requests"), SyncBackend.NIQUESTS: ("NiquestsSyncClient", "unihttp.clients.niquests"), SyncBackend.ZAPROS: ("ZaprosSyncClient", "unihttp.clients.zapros"), } -_ASYNC_BACKENDS: dict[AsyncBackend, tuple[str, str]] = { +ASYNC_BACKENDS: dict[AsyncBackend, tuple[str, str]] = { AsyncBackend.HTTPX: ("HTTPXAsyncClient", "unihttp.clients.httpx"), AsyncBackend.AIOHTTP: ("AiohttpAsyncClient", "unihttp.clients.aiohttp"), AsyncBackend.NIQUESTS: ("NiquestsAsyncClient", "unihttp.clients.niquests"), @@ -49,45 +51,16 @@ def async_client_name(title: str) -> str: def _imperative_method_lines(op: IROperation, attr: str, *, is_async: bool) -> list[str]: - """Render an explicit typed wrapper method delegating to ``self.call_method``. - - The parameter list mirrors the operation's ``BaseMethod`` dataclass fields - exactly (names, types, defaults), keyword-only, required first. - """ - fields = operation_fields(op) - params: list[str] = [] - for spec in fields: - if spec.required: - params.append(f"{spec.py_name}: {spec.inner}") - elif spec.has_default: - if spec.is_factory: - # Mutable defaults can't be literal arg defaults; wrap as Omittable. - params.append(f"{spec.py_name}: Omittable[{spec.inner}] = Omitted()") - else: - params.append(f"{spec.py_name}: {spec.inner} = {spec.default!r}") - else: - params.append(f"{spec.py_name}: Omittable[{spec.inner}] = Omitted()") - - signature = ", ".join(["self", "*", *params]) if params else "self" - return_anno = op.return_type.annotation() if op.return_type is not None else "None" - ctor_args = ", ".join(f"{spec.py_name}={spec.py_name}" for spec in fields) + """Render an explicit typed wrapper method delegating to ``self.call_method``.""" + ctor_args = ", ".join(f"{spec.py_name}={spec.py_name}" for spec in operation_fields(op)) call = f"self.call_method({op.class_name}({ctor_args}))" - prefix = "async def" if is_async else "def" body = f"return await {call}" if is_async else f"return {call}" return [ - f" {prefix} {attr}({signature}) -> {return_anno}:", + f" {method_signature(op, attr, is_async=is_async)}", f" {body}", ] -def _imperative_uses_omitted(ops: list[IROperation]) -> bool: - return any( - not spec.required and (not spec.has_default or spec.is_factory) - for op in ops - for spec in operation_fields(op) - ) - - def _auth_middleware_class(cred: AuthCredential, *, is_async: bool) -> str: base = "Header" if cred.transport == "header" else "Query" suffix = "Async" if is_async else "Sync" @@ -175,12 +148,20 @@ def _flat_root_client( return "\n".join(lines) +def flat_client_attributes(doc: IRDocument) -> list[tuple[str, IROperation]]: + """Attribute name per operation on a flat client, de-duplicated across all tags. + + The stub renderer reuses this: an attribute named differently there than in + ``client.py`` would simply not exist as far as a type checker is concerned. + """ + registry = NameRegistry() + return [(registry.reserve(op.method_name), op) for op in doc.operations] + + def _flat_method_lines(doc: IRDocument, style: MethodStyle, *, is_async: bool) -> list[str]: """Per-operation client members for a flat client (names globally de-duplicated).""" - registry = NameRegistry() lines: list[str] = [] - for op in doc.operations: - attr = registry.reserve(op.method_name) + for attr, op in flat_client_attributes(doc): if style is MethodStyle.IMPERATIVE: lines.extend(_imperative_method_lines(op, attr, is_async=is_async)) else: @@ -220,7 +201,7 @@ def render_client_module(doc: IRDocument, config: GeneratorConfig, package: str) imports |= op.imports() model_refs |= op.referenced_models() imports |= {Import(f"{package}.models", name) for name in model_refs} - if _imperative_uses_omitted(doc.operations): + if signatures_use_omitted(doc.operations): imports.add(Import("unihttp.omitted", "Omittable")) imports.add(Import("unihttp.omitted", "Omitted")) @@ -251,14 +232,14 @@ def emit_side(backend_cls: str, *, is_async: bool) -> None: deep_keys = deep_object_query_keys(doc) if config.emit_sync: - backend_cls, backend_mod = _SYNC_BACKENDS[config.sync_backend] + backend_cls, backend_mod = SYNC_BACKENDS[config.sync_backend] imports.add(Import(backend_mod, backend_cls)) imports.add(Import("unihttp.middlewares.error_mapper", "SyncErrorMapperMiddleware")) if deep_keys: imports.add(Import(f"{package}._query", "DeepObjectQuerySyncMiddleware")) emit_side(backend_cls, is_async=False) if config.emit_async: - backend_cls, backend_mod = _ASYNC_BACKENDS[config.async_backend] + backend_cls, backend_mod = ASYNC_BACKENDS[config.async_backend] imports.add(Import(backend_mod, backend_cls)) imports.add(Import("unihttp.middlewares.error_mapper", "AsyncErrorMapperMiddleware")) if deep_keys: diff --git a/src/unihttp_openapi_generator/render/methods.py b/src/unihttp_openapi_generator/render/methods.py index 21fb1da..dc715bd 100644 --- a/src/unihttp_openapi_generator/render/methods.py +++ b/src/unihttp_openapi_generator/render/methods.py @@ -122,6 +122,39 @@ def add( return [*required, *optional] +def method_signature(op: IROperation, attr: str, *, is_async: bool) -> str: + """The ``def`` header for ``op`` as a client method, without a body. + + Shared by the imperative client renderer and the stub renderer so the two can + never disagree on parameter names, types, order, or defaults. The parameter list + mirrors the operation's ``BaseMethod`` dataclass fields exactly, keyword-only, + required first. + """ + params: list[str] = [] + for spec in operation_fields(op): + if spec.required: + params.append(f"{spec.py_name}: {spec.inner}") + elif spec.has_default and not spec.is_factory: + params.append(f"{spec.py_name}: {spec.inner} = {spec.default!r}") + else: + # No default, or a mutable one that can't be a literal arg default. + params.append(f"{spec.py_name}: Omittable[{spec.inner}] = Omitted()") + + signature = ", ".join(["self", "*", *params]) if params else "self" + return_anno = op.return_type.annotation() if op.return_type is not None else "None" + prefix = "async def" if is_async else "def" + return f"{prefix} {attr}({signature}) -> {return_anno}:" + + +def signatures_use_omitted(ops: list[IROperation]) -> bool: + """Whether any signature from ``method_signature`` needs ``Omittable``/``Omitted``.""" + return any( + not spec.required and (not spec.has_default or spec.is_factory) + for op in ops + for spec in operation_fields(op) + ) + + def _collect_field_lines(op: IROperation) -> tuple[list[str], set[str], bool, bool]: """Return (ordered field lines, marker names used, uses_omitted, uses_field).""" lines: list[str] = [] diff --git a/src/unihttp_openapi_generator/render/stubs.py b/src/unihttp_openapi_generator/render/stubs.py new file mode 100644 index 0000000..758c593 --- /dev/null +++ b/src/unihttp_openapi_generator/render/stubs.py @@ -0,0 +1,148 @@ +"""Render ``client.pyi``: the client's public surface with explicit method signatures. + +``client.py`` binds operations declaratively (``get_pet = bind_method(GetPet)``). +mypy resolves the resulting ``ParamSpec``-through-descriptor overloads; PyCharm does +not, so the editor shows no signature for any operation. A stub sidesteps that +without touching the runtime module: type checkers and IDEs read ``client.pyi`` +instead, and it spells every operation out as a plain ``def``. + +Signatures come from :func:`~unihttp_openapi_generator.render.methods.method_signature`, +the same helper ``--style imperative`` uses, so the two renderings cannot drift. +""" + +from __future__ import annotations + +from unihttp_openapi_generator.config import GeneratorConfig, Layout +from unihttp_openapi_generator.ir.document import IRDocument +from unihttp_openapi_generator.ir.naming import class_name, field_name +from unihttp_openapi_generator.ir.operations import IROperation +from unihttp_openapi_generator.ir.types import Import +from unihttp_openapi_generator.render.auth import AuthCredential, iter_auth_credentials +from unihttp_openapi_generator.render.clients import ( + ASYNC_BACKENDS, + SYNC_BACKENDS, + async_client_name, + flat_client_attributes, + sync_client_name, +) +from unihttp_openapi_generator.render.engine import render_template +from unihttp_openapi_generator.render.imports import render_import_lines +from unihttp_openapi_generator.render.methods import method_signature, signatures_use_omitted +from unihttp_openapi_generator.render.serializers.base import docstring + + +def _method_lines(op: IROperation, attr: str, *, is_async: bool) -> list[str]: + """One stubbed operation: its signature, the spec's prose, and an empty body.""" + lines = [f" {method_signature(op, attr, is_async=is_async)}"] + doc_parts = [p for p in (op.summary, op.description) if p] + if op.deprecated: + doc_parts.append("Deprecated.") + # Same paragraph handling as the request dataclass docstring; PyCharm surfaces + # this on hover and in parameter info, which is most of the point of the stub. + doc = docstring("\n\n".join(doc_parts), " ") if doc_parts else "" + if doc: + lines.append(doc.rstrip("\n")) + lines.append(" ...") + return lines + + +def _init_line(doc: IRDocument, creds: list[AuthCredential]) -> str: + """``__init__`` as the runtime client declares it, minus the body.""" + default = "DEFAULT_BASE_URL" if doc.base_url else '""' + params = [ + f"base_url: str = {default}", + "*", + "session: Any = None", + "middleware: list[Any] | None = None", + ] + params.extend(f"{c.param_name}: {c.py_type} = None" for c in creds) + return f" def __init__(self, {', '.join(params)}) -> None: ..." + + +def _subclient(doc: IRDocument, tag: str, *, is_async: bool) -> str: + prefix = "Async" if is_async else "" + lines = [f"class {prefix}{class_name(tag)}Client:"] + lines.append(" def __init__(self, root: Any) -> None: ...") + kw = "async def" if is_async else "def" + lines.append( + f" {kw} call_method(self, method: BaseMethod[ResponseType]) -> ResponseType: ..." + ) + for op in doc.operations_for_tag(tag): + lines.extend(_method_lines(op, op.method_name, is_async=is_async)) + return "\n".join(lines) + + +def _grouped_root( + doc: IRDocument, backend_cls: str, creds: list[AuthCredential], *, is_async: bool +) -> str: + name = async_client_name(doc.title) if is_async else sync_client_name(doc.title) + prefix = "Async" if is_async else "" + lines = [f"class {name}({backend_cls}):"] + # The runtime assigns these in ``__init__``; a stub declares them as attributes. + lines.extend(f" {field_name(tag)}: {prefix}{class_name(tag)}Client" for tag in doc.tags) + lines.append(_init_line(doc, creds)) + return "\n".join(lines) + + +def _flat_root( + doc: IRDocument, backend_cls: str, creds: list[AuthCredential], *, is_async: bool +) -> str: + name = async_client_name(doc.title) if is_async else sync_client_name(doc.title) + lines = [f"class {name}({backend_cls}):", _init_line(doc, creds)] + for attr, op in flat_client_attributes(doc): + lines.extend(_method_lines(op, attr, is_async=is_async)) + return "\n".join(lines) + + +def _signature_imports(doc: IRDocument, package: str) -> set[Import]: + """Everything the spelled-out signatures reference.""" + imports: set[Import] = {Import("typing", "Any")} + model_refs: set[str] = set() + for op in doc.operations: + imports |= op.imports() + model_refs |= op.referenced_models() + imports |= {Import(f"{package}.models", name) for name in model_refs} + if signatures_use_omitted(doc.operations): + imports.add(Import("unihttp.omitted", "Omittable")) + imports.add(Import("unihttp.omitted", "Omitted")) + return imports + + +def render_client_stub(doc: IRDocument, config: GeneratorConfig, package: str) -> str: + grouped = config.resolve_layout(len(doc.tags)) is Layout.GROUPED + creds = iter_auth_credentials(doc) + + imports = _signature_imports(doc, package) + if grouped: + imports.add(Import("unihttp.method", "BaseMethod")) + imports.add(Import("unihttp.method", "ResponseType")) + + parts: list[str] = [] + if doc.servers: + parts.append("SERVERS: dict[str, str]") + if doc.base_url: + parts.append("DEFAULT_BASE_URL: str") + + def emit_side(backend_cls: str, *, is_async: bool) -> None: + if grouped: + parts.extend(_subclient(doc, tag, is_async=is_async) for tag in doc.tags) + parts.append(_grouped_root(doc, backend_cls, creds, is_async=is_async)) + else: + parts.append(_flat_root(doc, backend_cls, creds, is_async=is_async)) + + if config.emit_sync: + backend_cls, backend_mod = SYNC_BACKENDS[config.sync_backend] + imports.add(Import(backend_mod, backend_cls)) + emit_side(backend_cls, is_async=False) + if config.emit_async: + backend_cls, backend_mod = ASYNC_BACKENDS[config.async_backend] + imports.add(Import(backend_mod, backend_cls)) + emit_side(backend_cls, is_async=True) + + return render_template( + "module.py.jinja", + header_comment='"""Type stubs for the generated API client. Do not edit by hand."""', + future=False, + imports=render_import_lines(imports), + body="\n\n".join(parts), + ) diff --git a/tests/test_compile_gate.py b/tests/test_compile_gate.py index a69b9d1..f3d844a 100644 --- a/tests/test_compile_gate.py +++ b/tests/test_compile_gate.py @@ -11,7 +11,13 @@ import pytest -from unihttp_openapi_generator.config import ClientKind, FileLayout, GeneratorConfig, Serializer +from unihttp_openapi_generator.config import ( + ClientKind, + FileLayout, + GeneratorConfig, + Layout, + Serializer, +) from unihttp_openapi_generator.pipeline import run_generation _MATRIX = [ @@ -36,7 +42,8 @@ def hierarchy_spec_file(hierarchy_spec: dict[str, Any], tmp_path: Path) -> Path: def _collect_sources(root: Path) -> dict[str, str]: - return {str(p.relative_to(root)): p.read_text() for p in sorted(root.rglob("*.py"))} + paths = sorted([*root.rglob("*.py"), *root.rglob("*.pyi")]) + return {str(p.relative_to(root)): p.read_text() for p in paths} def _assert_ruff_clean(package_dir: Path) -> None: @@ -114,9 +121,59 @@ class is the one reference that has to be imported at *runtime* in the per-objec _assert_mypy_strict_clean(out / package) -def test_generation_is_deterministic(spec_file: Path, tmp_path: Path) -> None: - config_a = GeneratorConfig(package_name="det_client", output_dir=tmp_path / "a") - config_b = GeneratorConfig(package_name="det_client", output_dir=tmp_path / "b") +@pytest.mark.parametrize(("serializer", "client"), _MATRIX) +def test_stubbed_package_passes_ruff_and_both_mypy_passes( + spec_file: Path, tmp_path: Path, serializer: Serializer, client: ClientKind +) -> None: + """``--stubs --check`` runs the real gate, including the implementation pass. + + ``check=True`` is the point of the test rather than a shortcut: a stub takes + ``client.py`` out of mypy's sight, so only the pipeline's second pass proves the + runtime module is still clean, and only the first proves the stub itself is. + """ + package = f"stub_{serializer.value}" + out = tmp_path / package + run_generation( + str(spec_file), + GeneratorConfig( + package_name=package, + output_dir=out, + serializer=serializer, + client=client, + stubs=True, + check=True, + ), + ) + assert (out / package / "client.pyi").is_file() + + +@pytest.mark.parametrize("layout", list(Layout)) +@pytest.mark.parametrize("file_layout", list(FileLayout)) +def test_stubs_survive_every_layout( + spec_file: Path, tmp_path: Path, layout: Layout, file_layout: FileLayout +) -> None: + # Grouped layout adds sub-client classes to the stub, and the per-object file + # layout moves the models the signatures reference behind a re-exporting package. + package = f"stublay_{layout.name.lower()}_{file_layout.name.lower()}" + out = tmp_path / package + run_generation( + str(spec_file), + GeneratorConfig( + package_name=package, + output_dir=out, + layout=layout, + file_layout=file_layout, + stubs=True, + check=True, + ), + ) + assert (out / package / "client.pyi").is_file() + + +@pytest.mark.parametrize("stubs", [False, True]) +def test_generation_is_deterministic(spec_file: Path, tmp_path: Path, stubs: bool) -> None: + config_a = GeneratorConfig(package_name="det_client", output_dir=tmp_path / "a", stubs=stubs) + config_b = GeneratorConfig(package_name="det_client", output_dir=tmp_path / "b", stubs=stubs) run_generation(str(spec_file), config_a) run_generation(str(spec_file), config_b) sources_a = _collect_sources(tmp_path / "a" / "det_client") diff --git a/tests/test_config.py b/tests/test_config.py index 890dfdc..f4dc400 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,12 @@ import pytest from pydantic import ValidationError -from unihttp_openapi_generator.config import ClientKind, GeneratorConfig, Serializer +from unihttp_openapi_generator.config import ( + ClientKind, + GeneratorConfig, + MethodStyle, + Serializer, +) def _config(**overrides: object) -> GeneratorConfig: @@ -22,6 +27,14 @@ def test_defaults() -> None: assert cfg.client is ClientKind.BOTH assert cfg.style.value == "declarative" assert cfg.check is False + assert cfg.stubs is False + + +def test_rejects_stubs_with_imperative_style() -> None: + # Imperative clients already spell every signature out in ``client.py``; a stub + # would duplicate them and could drift from them. + with pytest.raises(ValidationError, match="imperative"): + _config(stubs=True, style=MethodStyle.IMPERATIVE) def test_rejects_invalid_package_name() -> None: diff --git a/tests/test_config_file.py b/tests/test_config_file.py index b3b5777..d5d2311 100644 --- a/tests/test_config_file.py +++ b/tests/test_config_file.py @@ -106,3 +106,52 @@ def test_cli_flag_overrides_config_file(spec_path: Path, tmp_path: Path) -> None def test_cli_missing_required_errors(tmp_path: Path) -> None: result = runner.invoke(app, ["generate", "--serializer", "adaptix"]) assert result.exit_code != 0 + + +def test_cli_stubs_flag_emits_a_stub(spec_path: Path, tmp_path: Path) -> None: + out = tmp_path / "out_stubs_cli" + result = runner.invoke( + app, + [ + "generate", + str(spec_path), + "-o", + str(out), + "--package-name", + "cli_stub_client", + "--stubs", + ], + ) + assert result.exit_code == 0, result.output + assert (out / "cli_stub_client" / "client.pyi").is_file() + + +def test_stubs_reaches_the_config_from_a_config_file(spec_path: Path, tmp_path: Path) -> None: + out = tmp_path / "out_stubs_toml" + cfg = _write( + tmp_path / "gen.toml", + f'spec = "{spec_path}"\noutput_dir = "{out}"\n' + 'package_name = "toml_stub_client"\nstubs = true\n', + ) + result = runner.invoke(app, ["generate", "--config", str(cfg)]) + assert result.exit_code == 0, result.output + assert (out / "toml_stub_client" / "client.pyi").is_file() + + +def test_cli_rejects_stubs_with_imperative_style(spec_path: Path, tmp_path: Path) -> None: + result = runner.invoke( + app, + [ + "generate", + str(spec_path), + "-o", + str(tmp_path / "out_bad"), + "--package-name", + "bad_client", + "--stubs", + "--style", + "imperative", + ], + ) + assert result.exit_code != 0 + assert "imperative" in result.output diff --git a/tests/test_emit.py b/tests/test_emit.py index d51373a..249e795 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -4,6 +4,7 @@ import importlib import json +import subprocess import sys from pathlib import Path from typing import Any @@ -12,6 +13,7 @@ from unihttp_openapi_generator.config import ClientKind, GeneratorConfig, Serializer from unihttp_openapi_generator.pipeline import run_generation +from unihttp_openapi_generator.tooling import ruff_executable @pytest.fixture @@ -76,3 +78,47 @@ def test_pyproject_pins_unihttp_floor(spec_file: Path, tmp_path: Path) -> None: run_generation(str(spec_file), config) pyproject = (out / "pyproject.toml").read_text() assert '"unihttp>=0.2.9"' in pyproject, pyproject + + +def test_no_stub_is_written_by_default(spec_file: Path, tmp_path: Path) -> None: + out = tmp_path / "out_nostub" + run_generation( + str(spec_file), + GeneratorConfig(package_name="nostub_client", output_dir=out), + ) + assert not (out / "nostub_client" / "client.pyi").exists() + + +def test_stubs_writes_a_formatted_client_pyi(spec_file: Path, tmp_path: Path) -> None: + out = tmp_path / "out_stub" + run_generation( + str(spec_file), + GeneratorConfig(package_name="stub_client", output_dir=out, stubs=True), + ) + stub = out / "stub_client" / "client.pyi" + assert stub.is_file() + source = stub.read_text() + # The runtime module is untouched and still binds declaratively... + assert "bind_method" in (out / "stub_client" / "client.py").read_text() + # ...while the stub spells the same operations out. + assert "def list_pets(" in source + assert "bind_method" not in source + # Module-level names are declared without values, and ruff's stub-mode formatting + # keeps the empty ``__init__`` body as ``...``. + assert "DEFAULT_BASE_URL: str\n" in source + assert "base_url: str = DEFAULT_BASE_URL," in source + assert ") -> None: ..." in source + + +def test_generated_stub_is_ruff_clean(spec_file: Path, tmp_path: Path) -> None: + out = tmp_path / "out_stub_lint" + run_generation( + str(spec_file), + GeneratorConfig(package_name="lint_client", output_dir=out, stubs=True), + ) + result = subprocess.run( + [ruff_executable(), "check", "--isolated", str(out / "lint_client" / "client.pyi")], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/test_pipeline_cli_extra.py b/tests/test_pipeline_cli_extra.py index f50609b..6158045 100644 --- a/tests/test_pipeline_cli_extra.py +++ b/tests/test_pipeline_cli_extra.py @@ -10,7 +10,12 @@ from unihttp_openapi_generator import __version__ from unihttp_openapi_generator.cli import app -from unihttp_openapi_generator.pipeline import CheckError, _mypy_args, _run_check +from unihttp_openapi_generator.pipeline import ( + CheckError, + _check_package, + _mypy_args, + _run_check, +) runner = CliRunner() @@ -53,3 +58,19 @@ def test_invalid_package_name_is_bad_parameter(tmp_path: Path) -> None: # the pydantic ValidationError (a ValueError) is surfaced as a BadParameter assert "Invalid value" in result.output assert "GeneratorConfig" in result.output + + +def test_check_looks_past_a_stub_at_the_implementation(tmp_path: Path) -> None: + # A ``.pyi`` takes a module out of mypy's sight completely, so ``--stubs`` adds a + # second pass that excludes the stub -- otherwise ``client.py`` would ship + # unchecked, and any stub/implementation mismatch would go unnoticed. + pkg = tmp_path / "stubbed" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "client.py").write_text("def broken() -> int:\n return 'not an int'\n") + (pkg / "client.pyi").write_text("def broken() -> int: ...\n") + + _check_package(pkg, stubs=False) # single pass: the stub wins, the error hides + + with pytest.raises(CheckError, match="Incompatible return value"): + _check_package(pkg, stubs=True) diff --git a/tests/test_render_stubs.py b/tests/test_render_stubs.py new file mode 100644 index 0000000..9d4fb5c --- /dev/null +++ b/tests/test_render_stubs.py @@ -0,0 +1,246 @@ +"""Tests for ``client.pyi`` rendering (``--stubs``).""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from unihttp_openapi_generator.config import ClientKind, GeneratorConfig, Layout +from unihttp_openapi_generator.ir.builder import build_ir +from unihttp_openapi_generator.refs import RefResolver +from unihttp_openapi_generator.render.clients import render_client_module +from unihttp_openapi_generator.render.stubs import render_client_stub + + +def _config(**kwargs: Any) -> GeneratorConfig: + base: dict[str, Any] = {"package_name": "pkg", "output_dir": Path("out"), "stubs": True} + base.update(kwargs) + return GeneratorConfig(**base) + + +def _render(spec: dict[str, Any], **config_kwargs: Any) -> str: + doc = build_ir(spec, RefResolver(spec)) + return render_client_stub(doc, _config(**config_kwargs), "pkg") + + +_PET: dict[str, Any] = { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], +} + +_SPEC: dict[str, Any] = { + "openapi": "3.1.0", + "info": {"title": "Sample", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com/v1"}], + "paths": { + "/pets/{petId}": { + "get": { + "operationId": "getPet", + "tags": ["pets"], + "summary": "Fetch one pet", + "parameters": [ + { + "name": "petId", + "in": "path", + "required": True, + "schema": {"type": "string"}, + }, + {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 10}}, + {"name": "q", "in": "query", "schema": {"type": "string"}}, + ], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/Pet"}} + }, + } + }, + } + } + }, + "components": {"schemas": {"Pet": _PET}}, +} + + +def test_operation_becomes_an_explicit_def() -> None: + out = _render(_SPEC, client=ClientKind.SYNC) + assert ( + "def get_pet(self, *, pet_id: str, limit: int = 10, q: Omittable[str] = Omitted()) -> Pet:" + in out + ) + + +def test_stub_does_not_bind_methods() -> None: + out = _render(_SPEC, client=ClientKind.SYNC) + assert "bind_method" not in out + + +def test_stub_carries_the_operation_docstring() -> None: + out = _render(_SPEC, client=ClientKind.SYNC) + assert '"""Fetch one pet"""' in out + + +def test_stub_declares_module_level_names_without_values() -> None: + out = _render(_SPEC, client=ClientKind.SYNC) + assert "SERVERS: dict[str, str]\n" in out + assert "DEFAULT_BASE_URL: str\n" in out + assert "SERVERS: dict[str, str] = {" not in out + + +def test_stub_has_no_future_import() -> None: + # Stub files are never evaluated; forward references are implicitly lazy. + out = _render(_SPEC, client=ClientKind.SYNC) + assert "from __future__ import annotations" not in out + + +def test_stub_init_mirrors_the_runtime_signature() -> None: + out = _render(_SPEC, client=ClientKind.SYNC) + assert ( + "def __init__(self, base_url: str = DEFAULT_BASE_URL, *, session: Any = None, " + "middleware: list[Any] | None = None) -> None: ..." in out + ) + + +def test_sync_client_subclasses_the_configured_backend() -> None: + out = _render(_SPEC, client=ClientKind.SYNC) + assert "class SampleClient(RequestsSyncClient):" in out + assert "from unihttp.clients.requests import RequestsSyncClient" in out + + +def test_async_operations_are_coroutines() -> None: + out = _render(_SPEC, client=ClientKind.ASYNC) + assert "class AsyncSampleClient(AiohttpAsyncClient):" in out + assert "async def get_pet(self, *, pet_id: str" in out + + +def test_both_kinds_emit_both_clients() -> None: + out = _render(_SPEC, client=ClientKind.BOTH) + assert "class SampleClient(RequestsSyncClient):" in out + assert "class AsyncSampleClient(AiohttpAsyncClient):" in out + + +def test_grouped_layout_emits_subclients_bound_by_annotation() -> None: + out = _render(_SPEC, client=ClientKind.SYNC, layout=Layout.GROUPED) + assert "class PetsClient:" in out + assert " def __init__(self, root: Any) -> None: ..." in out + assert "def call_method(self, method: BaseMethod[ResponseType]) -> ResponseType: ..." in out + assert " pets: PetsClient\n" in out + + +def test_grouped_async_subclient_is_awaitable() -> None: + out = _render(_SPEC, client=ClientKind.ASYNC, layout=Layout.GROUPED) + assert "class AsyncPetsClient:" in out + assert ( + "async def call_method(self, method: BaseMethod[ResponseType]) -> ResponseType: ..." in out + ) + assert " pets: AsyncPetsClient\n" in out + + +_AUTH_SPEC: dict[str, Any] = { + "openapi": "3.1.0", + "info": {"title": "Auth", "version": "1.0.0"}, + "components": { + "securitySchemes": { + "apiKey": {"type": "apiKey", "in": "header", "name": "X-Key"}, + } + }, + "security": [{"apiKey": []}], + "paths": { + "/x": { + "get": { + "operationId": "getX", + "tags": ["x"], + "responses": {"200": {"description": "ok"}}, + } + } + }, +} + + +def test_auth_credentials_reach_the_stub_init() -> None: + out = _render(_AUTH_SPEC, client=ClientKind.SYNC) + assert "api_key: str | None = None" in out + + +_NO_PARAM_SPEC: dict[str, Any] = { + "openapi": "3.1.0", + "info": {"title": "Bare", "version": "1.0.0"}, + "paths": { + "/ping": { + "get": { + "operationId": "ping", + "tags": ["ops"], + "responses": {"204": {"description": "no content"}}, + } + } + }, +} + + +def test_parameterless_operation_renders_without_a_keyword_marker() -> None: + out = _render(_NO_PARAM_SPEC, client=ClientKind.SYNC) + assert "def ping(self) -> None:" in out + # No servers in this spec, so neither module-level constant is declared. + assert "DEFAULT_BASE_URL" not in out + assert 'base_url: str = ""' in out + + +_COLLIDING_SPEC: dict[str, Any] = { + "openapi": "3.1.0", + "info": {"title": "Collide", "version": "1.0.0"}, + "paths": { + "/a": { + "get": { + "operationId": "get_thing", + "tags": ["a"], + "responses": {"200": {"description": "ok"}}, + } + }, + "/b": { + "get": { + "operationId": "getThing", + "tags": ["b"], + "responses": {"200": {"description": "ok"}}, + } + }, + }, +} + + +def test_flat_stub_attribute_names_match_the_runtime_client() -> None: + # Whatever the runtime client ends up calling each operation -- including any + # de-duplication of colliding names -- the stub must call it the same thing, or + # the attribute simply does not exist as far as a type checker is concerned. + config = _config(client=ClientKind.SYNC, layout=Layout.FLAT) + doc = build_ir(_COLLIDING_SPEC, RefResolver(_COLLIDING_SPEC)) + runtime = render_client_module(doc, config, "pkg") + out = render_client_stub(doc, config, "pkg") + + bound = re.findall(r"^ (\w+) = bind_method\(", runtime, re.MULTILINE) + assert len(bound) == 2 and len(set(bound)) == 2 + for attr in bound: + assert f"def {attr}(self)" in out + + +_DEPRECATED_SPEC: dict[str, Any] = { + "openapi": "3.1.0", + "info": {"title": "Old", "version": "1.0.0"}, + "paths": { + "/legacy": { + "get": { + "operationId": "getLegacy", + "tags": ["legacy"], + "deprecated": True, + "responses": {"200": {"description": "ok"}}, + } + } + }, +} + + +def test_deprecated_operation_is_marked_in_the_stub_docstring() -> None: + out = _render(_DEPRECATED_SPEC, client=ClientKind.SYNC) + assert '"""Deprecated."""' in out From 7cf75564f2b64a8854611ca86ecfee01e2feac68 Mon Sep 17 00:00:00 2001 From: goduni <37146584+goduni@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:54:35 +0000 Subject: [PATCH 2/4] fix: move a client method's receiver aside when a parameter is named '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. --- .../render/clients.py | 3 +- .../render/methods.py | 19 +++++++- tests/test_clients_extra.py | 25 ++++++++++ tests/test_compile_gate.py | 46 +++++++++++++++++++ tests/test_render_stubs.py | 27 +++++++++++ 5 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/unihttp_openapi_generator/render/clients.py b/src/unihttp_openapi_generator/render/clients.py index e157637..a31d44b 100644 --- a/src/unihttp_openapi_generator/render/clients.py +++ b/src/unihttp_openapi_generator/render/clients.py @@ -21,6 +21,7 @@ from unihttp_openapi_generator.render.engine import render_template from unihttp_openapi_generator.render.imports import render_import_lines from unihttp_openapi_generator.render.methods import ( + method_receiver, method_signature, operation_fields, signatures_use_omitted, @@ -53,7 +54,7 @@ def async_client_name(title: str) -> str: def _imperative_method_lines(op: IROperation, attr: str, *, is_async: bool) -> list[str]: """Render an explicit typed wrapper method delegating to ``self.call_method``.""" ctor_args = ", ".join(f"{spec.py_name}={spec.py_name}" for spec in operation_fields(op)) - call = f"self.call_method({op.class_name}({ctor_args}))" + call = f"{method_receiver(op)}.call_method({op.class_name}({ctor_args}))" body = f"return await {call}" if is_async else f"return {call}" return [ f" {method_signature(op, attr, is_async=is_async)}", diff --git a/src/unihttp_openapi_generator/render/methods.py b/src/unihttp_openapi_generator/render/methods.py index dc715bd..ed9da32 100644 --- a/src/unihttp_openapi_generator/render/methods.py +++ b/src/unihttp_openapi_generator/render/methods.py @@ -122,6 +122,22 @@ def add( return [*required, *optional] +def method_receiver(op: IROperation) -> str: + """The name to give a client method's receiver -- ``self`` unless a parameter took it. + + ``self`` is a perfectly legal parameter name in a spec, and the request dataclass + carries it as a field: ``dataclasses`` renames *its* receiver to + ``__dataclass_self__`` in that case, so ``GetA(self=...)`` works at runtime. A + client method has to do the same, or it emits ``def get_a(self, *, self: str)`` -- + a duplicate-argument syntax error that takes the whole generation down. + """ + taken = {spec.py_name for spec in operation_fields(op)} + receiver = "self" + while receiver in taken: + receiver += "_" + return receiver + + def method_signature(op: IROperation, attr: str, *, is_async: bool) -> str: """The ``def`` header for ``op`` as a client method, without a body. @@ -140,7 +156,8 @@ def method_signature(op: IROperation, attr: str, *, is_async: bool) -> str: # No default, or a mutable one that can't be a literal arg default. params.append(f"{spec.py_name}: Omittable[{spec.inner}] = Omitted()") - signature = ", ".join(["self", "*", *params]) if params else "self" + receiver = method_receiver(op) + signature = ", ".join([receiver, "*", *params]) if params else receiver return_anno = op.return_type.annotation() if op.return_type is not None else "None" prefix = "async def" if is_async else "def" return f"{prefix} {attr}({signature}) -> {return_anno}:" diff --git a/tests/test_clients_extra.py b/tests/test_clients_extra.py index 8a9da4a..cc36248 100644 --- a/tests/test_clients_extra.py +++ b/tests/test_clients_extra.py @@ -118,3 +118,28 @@ def test_async_deep_object_middleware_import() -> None: def test_basic_auth_prepends_base64_import() -> None: out = _render(_BASIC_AUTH_SPEC, client=ClientKind.SYNC) assert "import base64\n" in out + + +def test_imperative_method_shadowed_by_a_self_parameter() -> None: + # The receiver moves aside, so the delegating body has to follow it. + out = _render(_SELF_PARAM_SPEC, style=MethodStyle.IMPERATIVE, client=ClientKind.SYNC) + assert "def get_a(self_, *, self: str) -> None:" in out + assert "return self_.call_method(GetA(self=self))" in out + + +_SELF_PARAM_SPEC: dict[str, Any] = { + "openapi": "3.1.0", + "info": {"title": "Shadow", "version": "1.0.0"}, + "paths": { + "/a": { + "get": { + "operationId": "getA", + "tags": ["t"], + "parameters": [ + {"name": "self", "in": "query", "required": True, "schema": {"type": "string"}} + ], + "responses": {"200": {"description": "ok"}}, + } + } + }, +} diff --git a/tests/test_compile_gate.py b/tests/test_compile_gate.py index f3d844a..8117763 100644 --- a/tests/test_compile_gate.py +++ b/tests/test_compile_gate.py @@ -16,6 +16,7 @@ FileLayout, GeneratorConfig, Layout, + MethodStyle, Serializer, ) from unihttp_openapi_generator.pipeline import run_generation @@ -179,3 +180,48 @@ def test_generation_is_deterministic(spec_file: Path, tmp_path: Path, stubs: boo sources_a = _collect_sources(tmp_path / "a" / "det_client") sources_b = _collect_sources(tmp_path / "b" / "det_client") assert sources_a == sources_b + + +def test_a_parameter_named_self_still_generates(tmp_path: Path) -> None: + """``self`` as a parameter name must not break the spelled-out signatures. + + The request dataclass takes it as a field -- ``dataclasses`` moves its own + receiver aside so ``GetA(self=...)`` works -- so a client method has to move + its receiver too. Both styles that spell signatures out are checked. + """ + spec = tmp_path / "self.json" + spec.write_text( + json.dumps( + { + "openapi": "3.1.0", + "info": {"title": "Shadow", "version": "1.0.0"}, + "paths": { + "/a": { + "get": { + "operationId": "getA", + "tags": ["t"], + "parameters": [ + { + "name": "self", + "in": "query", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + ) + ) + for name, extra in ( + ("shadow_stub", {"stubs": True}), + ("shadow_imp", {"style": MethodStyle.IMPERATIVE}), + ): + out = tmp_path / name + run_generation( + str(spec), + GeneratorConfig(package_name=name, output_dir=out, check=True, **extra), + ) + assert (out / name / "client.py").is_file() diff --git a/tests/test_render_stubs.py b/tests/test_render_stubs.py index 9d4fb5c..0aa53a2 100644 --- a/tests/test_render_stubs.py +++ b/tests/test_render_stubs.py @@ -244,3 +244,30 @@ def test_flat_stub_attribute_names_match_the_runtime_client() -> None: def test_deprecated_operation_is_marked_in_the_stub_docstring() -> None: out = _render(_DEPRECATED_SPEC, client=ClientKind.SYNC) assert '"""Deprecated."""' in out + + +_SELF_PARAM_SPEC: dict[str, Any] = { + "openapi": "3.1.0", + "info": {"title": "Shadow", "version": "1.0.0"}, + "paths": { + "/a": { + "get": { + "operationId": "getA", + "tags": ["t"], + "parameters": [ + {"name": "self", "in": "query", "required": True, "schema": {"type": "string"}} + ], + "responses": {"200": {"description": "ok"}}, + } + } + }, +} + + +def test_parameter_named_self_moves_the_receiver_aside() -> None: + # ``self`` is a legal query-parameter name and a legal dataclass field name -- + # dataclasses itself renames the receiver to ``__dataclass_self__`` for this case, + # so ``GetA(self=...)`` works at runtime. A stub that wrote ``def get_a(self, *, + # self: str)`` would be a syntax error and take the whole generation down with it. + out = _render(_SELF_PARAM_SPEC, client=ClientKind.SYNC) + assert "def get_a(self_, *, self: str) -> None:" in out From 61113dd4954f3cad7d5c1af49bbd6bd80e0691b1 Mon Sep 17 00:00:00 2001 From: goduni <37146584+goduni@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:57:16 +0000 Subject: [PATCH 3/4] fix: drop a stale client.pyi when regenerating without --stubs 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. --- src/unihttp_openapi_generator/emit.py | 6 ++++++ tests/test_emit.py | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/unihttp_openapi_generator/emit.py b/src/unihttp_openapi_generator/emit.py index 726176f..15e001c 100644 --- a/src/unihttp_openapi_generator/emit.py +++ b/src/unihttp_openapi_generator/emit.py @@ -206,6 +206,12 @@ def write_package(doc: IRDocument, config: GeneratorConfig) -> Path: _write_py(package_dir / "client.py", render_client_module(doc, config, package)) if config.stubs: _write_py(package_dir / "client.pyi", render_client_stub(doc, config, package)) + else: + # Generation writes over an existing package rather than clearing it, and a + # stub overrides ``client.py`` for every type checker and IDE that reads it. + # Left behind by a previous ``--stubs`` run it would keep serving that run's + # signatures -- worse than no stub once the spec has moved on. + (package_dir / "client.pyi").unlink(missing_ok=True) _write_py(package_dir / "__init__.py", _render_package_init(doc, config, package)) (package_dir / "py.typed").write_text("") diff --git a/tests/test_emit.py b/tests/test_emit.py index 249e795..73a87b5 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -122,3 +122,24 @@ def test_generated_stub_is_ruff_clean(spec_file: Path, tmp_path: Path) -> None: text=True, ) assert result.returncode == 0, result.stdout + result.stderr + + +def test_regenerating_without_stubs_removes_a_stale_one(spec_file: Path, tmp_path: Path) -> None: + # Generation writes over an existing package rather than clearing it, and a stub + # keeps overriding client.py for every type checker and IDE that reads it. Left + # behind, it would serve the previous run's signatures forever -- worse than no + # stub at all once the spec has moved on. + out = tmp_path / "out_stale" + stub = out / "stale_client" / "client.pyi" + + run_generation( + str(spec_file), + GeneratorConfig(package_name="stale_client", output_dir=out, stubs=True), + ) + assert stub.is_file() + + run_generation( + str(spec_file), + GeneratorConfig(package_name="stale_client", output_dir=out, stubs=False), + ) + assert not stub.exists() From 62da804a77aee93f2cb034d2d778df7efcb9a5a6 Mon Sep 17 00:00:00 2001 From: goduni <37146584+goduni@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:59:10 +0000 Subject: [PATCH 4/4] docs: the imperative wrapper's receiver is not always named self --- src/unihttp_openapi_generator/render/clients.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unihttp_openapi_generator/render/clients.py b/src/unihttp_openapi_generator/render/clients.py index a31d44b..ea4b3d1 100644 --- a/src/unihttp_openapi_generator/render/clients.py +++ b/src/unihttp_openapi_generator/render/clients.py @@ -52,7 +52,7 @@ def async_client_name(title: str) -> str: def _imperative_method_lines(op: IROperation, attr: str, *, is_async: bool) -> list[str]: - """Render an explicit typed wrapper method delegating to ``self.call_method``.""" + """Render an explicit typed wrapper method delegating to the client's ``call_method``.""" ctor_args = ", ".join(f"{spec.py_name}={spec.py_name}" for spec in operation_fields(op)) call = f"{method_receiver(op)}.call_method({op.class_name}({ctor_args}))" body = f"return await {call}" if is_async else f"return {call}"