Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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`.
Expand All @@ -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*
Expand Down
9 changes: 9 additions & 0 deletions src/unihttp_openapi_generator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -97,6 +105,7 @@ def generate(
"file_layout": file_layout,
"strip_prefix": strip_prefix,
"inheritance": inheritance,
"stubs": stubs,
"check": check,
}

Expand Down
6 changes: 6 additions & 0 deletions src/unihttp_openapi_generator/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/unihttp_openapi_generator/config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"file_layout",
"strip_prefix",
"inheritance",
"stubs",
"check",
}
)
Expand Down
9 changes: 9 additions & 0 deletions src/unihttp_openapi_generator/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -203,6 +204,14 @@ 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))
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("")

Expand Down
13 changes: 11 additions & 2 deletions src/unihttp_openapi_generator/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
64 changes: 23 additions & 41 deletions src/unihttp_openapi_generator/render/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,21 @@
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,
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"),
Expand All @@ -49,45 +52,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)
call = f"self.call_method({op.class_name}({ctor_args}))"
prefix = "async def" if is_async else "def"
"""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}"
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"
Expand Down Expand Up @@ -175,12 +149,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:
Expand Down Expand Up @@ -220,7 +202,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"))

Expand Down Expand Up @@ -251,14 +233,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:
Expand Down
50 changes: 50 additions & 0 deletions src/unihttp_openapi_generator/render/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,56 @@ 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.

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()")

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}:"


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] = []
Expand Down
Loading
Loading