From 43f5c981f1307b040dd79146e2ca391c766195da Mon Sep 17 00:00:00 2001 From: goduni <37146584+goduni@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:29:19 +0000 Subject: [PATCH 1/2] feat: render a property's description as an attribute docstring A schema's `description` became a class docstring, but a property's went nowhere: the IR carried it and every serializer dropped it, so prose the spec author wrote for a field was invisible in the generated client. Emit it as a PEP 258 attribute docstring under the field. That is the device the method classes already use for parameters and body fields, and it is the only one that renders identically for all three serializers: pydantic would otherwise need `Field(description=...)` on every documented field, msgspec an `Annotated[..., Meta(description=...)]`, and adaptix has no equivalent at all. It is also inert -- the class body just evaluates a string -- so constructors, decoding, and the emitted JSON schema are untouched, while editors show the prose on hover. Wrapping, embedded quotes, and backslashes are handled by the existing `docstring` helper, so a raw string is used where the prose needs one. The committed example libraries are regenerated. Most of that diff predates this change: they were already stale by 31 lines' worth of method-parameter docstrings from an earlier release. The 5 lines under `models/` are this one. --- README.md | 9 ++++ .../methods/default/get_latest_rates.py | 3 ++ .../methods/default/get_rates_for_date.py | 4 ++ .../methods/default/get_time_series.py | 5 ++ .../methods/default/get_time_series_to_now.py | 4 ++ .../models/exchange_rates.py | 4 ++ .../models/time_series_rates.py | 1 + .../methods/comments/list_comments.py | 1 + .../methods/posts/delete_post.py | 1 + .../methods/posts/get_post.py | 1 + .../methods/posts/get_post_comments.py | 1 + .../methods/posts/list_posts.py | 1 + .../methods/posts/update_post.py | 1 + .../methods/todos/get_todo.py | 1 + .../methods/todos/list_todos.py | 1 + .../methods/users/get_user.py | 1 + .../methods/default/get_forecast.py | 6 +++ .../render/serializers/adaptix.py | 1 + .../render/serializers/base.py | 17 +++++++ .../render/serializers/msgspec.py | 1 + .../render/serializers/pydantic.py | 1 + tests/test_render_models.py | 48 +++++++++++++++++++ 22 files changed, 113 insertions(+) diff --git a/README.md b/README.md index c2973df..939f940 100644 --- a/README.md +++ b/README.md @@ -539,6 +539,15 @@ Notes: (excluded from request bodies). - Operations: path/query/header parameters with defaults, JSON/form/multipart bodies, file uploads, typed responses, and `deprecated`. +- Prose: a schema's `description` becomes a class docstring, and a property's becomes a + PEP 258 attribute docstring under the field — so editors show it on hover: + ```python + class Pet(BaseModel): + id: int + """Server-assigned identifier.""" + ``` + The same already holds for parameters and body fields on request classes. Attribute + docstrings are inert at runtime, so nothing about construction or decoding changes. - Security: apiKey, http bearer/basic, oauth2, openIdConnect. ## Checking the output — `--check` diff --git a/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_latest_rates.py b/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_latest_rates.py index 147123a..c8798b2 100644 --- a/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_latest_rates.py +++ b/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_latest_rates.py @@ -22,5 +22,8 @@ class GetLatestRates(BaseMethod[ExchangeRates]): __method__ = "GET" base: Query[Omittable[str]] = Omitted() + """Base currency to quote against (default EUR).""" symbols: Query[Omittable[list[str]]] = Omitted() + """Limit the response to these target currencies.""" amount: Query[Omittable[float]] = Omitted() + """Amount to convert (default 1).""" diff --git a/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_rates_for_date.py b/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_rates_for_date.py index 4001f3f..53d0ee8 100644 --- a/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_rates_for_date.py +++ b/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_rates_for_date.py @@ -24,6 +24,10 @@ class GetRatesForDate(BaseMethod[ExchangeRates]): __method__ = "GET" date: Path[str] + """ISO date, e.g. 2020-01-02.""" base: Query[Omittable[str]] = Omitted() + """Base currency to quote against (default EUR).""" symbols: Query[Omittable[list[str]]] = Omitted() + """Limit the response to these target currencies.""" amount: Query[Omittable[float]] = Omitted() + """Amount to convert (default 1).""" diff --git a/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_time_series.py b/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_time_series.py index eeb1bb9..9f4480e 100644 --- a/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_time_series.py +++ b/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_time_series.py @@ -23,7 +23,12 @@ class GetTimeSeries(BaseMethod[TimeSeriesRates]): __method__ = "GET" start_date: Path[str] + """ISO start date, e.g. 2020-01-01.""" end_date: Path[str] + """ISO end date, e.g. 2020-01-31.""" base: Query[Omittable[str]] = Omitted() + """Base currency to quote against (default EUR).""" symbols: Query[Omittable[list[str]]] = Omitted() + """Limit the response to these target currencies.""" amount: Query[Omittable[float]] = Omitted() + """Amount to convert (default 1).""" diff --git a/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_time_series_to_now.py b/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_time_series_to_now.py index 95fcb8a..bcbc40f 100644 --- a/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_time_series_to_now.py +++ b/examples/frankfurter/frankfurter_library/frankfurter_client/methods/default/get_time_series_to_now.py @@ -22,6 +22,10 @@ class GetTimeSeriesToNow(BaseMethod[TimeSeriesRates]): __method__ = "GET" start_date: Path[str] + """ISO start date, e.g. 2024-01-01.""" base: Query[Omittable[str]] = Omitted() + """Base currency to quote against (default EUR).""" symbols: Query[Omittable[list[str]]] = Omitted() + """Limit the response to these target currencies.""" amount: Query[Omittable[float]] = Omitted() + """Amount to convert (default 1).""" diff --git a/examples/frankfurter/frankfurter_library/frankfurter_client/models/exchange_rates.py b/examples/frankfurter/frankfurter_library/frankfurter_client/models/exchange_rates.py index c6bb916..be4d749 100644 --- a/examples/frankfurter/frankfurter_library/frankfurter_client/models/exchange_rates.py +++ b/examples/frankfurter/frankfurter_library/frankfurter_client/models/exchange_rates.py @@ -11,6 +11,10 @@ class ExchangeRates: """Reference rates for a base currency on a given date.""" amount: float + """The amount that was converted.""" base: str + """The base currency the rates are quoted against.""" date: date + """The date the rates apply to.""" rates: dict[str, float] + """Target currency code -> rate.""" diff --git a/examples/frankfurter/frankfurter_library/frankfurter_client/models/time_series_rates.py b/examples/frankfurter/frankfurter_library/frankfurter_client/models/time_series_rates.py index 4ca4f9a..6fdedbe 100644 --- a/examples/frankfurter/frankfurter_library/frankfurter_client/models/time_series_rates.py +++ b/examples/frankfurter/frankfurter_library/frankfurter_client/models/time_series_rates.py @@ -15,3 +15,4 @@ class TimeSeriesRates: start_date: date end_date: date rates: dict[str, dict[str, float]] + """ISO date -> (currency code -> rate).""" diff --git a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/comments/list_comments.py b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/comments/list_comments.py index a2465ec..d9c1dd6 100644 --- a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/comments/list_comments.py +++ b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/comments/list_comments.py @@ -19,3 +19,4 @@ class ListComments(BaseMethod[list[Comment]]): __method__ = "GET" post_id: Query[Omittable[int]] = Omitted() + """Only comments on this post.""" diff --git a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/delete_post.py b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/delete_post.py index 7fe2fa9..ce31684 100644 --- a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/delete_post.py +++ b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/delete_post.py @@ -17,3 +17,4 @@ class DeletePost(BaseMethod[dict[str, Any]]): __method__ = "DELETE" id: Path[int] + """Resource id.""" diff --git a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/get_post.py b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/get_post.py index 162f594..357787d 100644 --- a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/get_post.py +++ b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/get_post.py @@ -18,3 +18,4 @@ class GetPost(BaseMethod[Post]): __method__ = "GET" id: Path[int] + """Resource id.""" diff --git a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/get_post_comments.py b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/get_post_comments.py index 58540ad..69d6c0c 100644 --- a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/get_post_comments.py +++ b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/get_post_comments.py @@ -18,3 +18,4 @@ class GetPostComments(BaseMethod[list[Comment]]): __method__ = "GET" id: Path[int] + """Resource id.""" diff --git a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/list_posts.py b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/list_posts.py index d123d58..2e7bbed 100644 --- a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/list_posts.py +++ b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/list_posts.py @@ -19,3 +19,4 @@ class ListPosts(BaseMethod[list[Post]]): __method__ = "GET" user_id: Query[Omittable[int]] = Omitted() + """Only posts by this user.""" diff --git a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/update_post.py b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/update_post.py index 4491aa6..c1c6a9d 100644 --- a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/update_post.py +++ b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/posts/update_post.py @@ -18,6 +18,7 @@ class UpdatePost(BaseMethod[Post]): __method__ = "PUT" id: Path[int] + """Resource id.""" user_id: Body[int] title: Body[str] body: Body[str] diff --git a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/todos/get_todo.py b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/todos/get_todo.py index 521cd6a..6042089 100644 --- a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/todos/get_todo.py +++ b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/todos/get_todo.py @@ -18,3 +18,4 @@ class GetTodo(BaseMethod[Todo]): __method__ = "GET" id: Path[int] + """Resource id.""" diff --git a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/todos/list_todos.py b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/todos/list_todos.py index acfb987..afaaf88 100644 --- a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/todos/list_todos.py +++ b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/todos/list_todos.py @@ -19,3 +19,4 @@ class ListTodos(BaseMethod[list[Todo]]): __method__ = "GET" user_id: Query[Omittable[int]] = Omitted() + """Only todos for this user.""" diff --git a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/users/get_user.py b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/users/get_user.py index 31f7ac7..c0844be 100644 --- a/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/users/get_user.py +++ b/examples/jsonplaceholder/jsonplaceholder_library/jsonplaceholder_client/methods/users/get_user.py @@ -18,3 +18,4 @@ class GetUser(BaseMethod[User]): __method__ = "GET" id: Path[int] + """Resource id.""" diff --git a/examples/open_meteo/open_meteo_library/open_meteo_client/methods/default/get_forecast.py b/examples/open_meteo/open_meteo_library/open_meteo_client/methods/default/get_forecast.py index 51e1ee4..a04bfe7 100644 --- a/examples/open_meteo/open_meteo_library/open_meteo_client/methods/default/get_forecast.py +++ b/examples/open_meteo/open_meteo_library/open_meteo_client/methods/default/get_forecast.py @@ -22,8 +22,14 @@ class GetForecast(BaseMethod[Forecast]): __method__ = "GET" latitude: Query[float] + """Latitude in decimal degrees.""" longitude: Query[float] + """Longitude in decimal degrees.""" current: Query[Omittable[list[str]]] = Omitted() + """Current-condition variables to return.""" hourly: Query[Omittable[list[str]]] = Omitted() + """Hourly variables to return.""" timezone: Query[Omittable[str]] = Omitted() + """Timezone name (e.g. UTC) or "auto".""" forecast_days: Query[Omittable[int]] = Omitted() + """Number of forecast days (1-16).""" diff --git a/src/unihttp_openapi_generator/render/serializers/adaptix.py b/src/unihttp_openapi_generator/render/serializers/adaptix.py index c580b96..3ff7258 100644 --- a/src/unihttp_openapi_generator/render/serializers/adaptix.py +++ b/src/unihttp_openapi_generator/render/serializers/adaptix.py @@ -64,6 +64,7 @@ def render_model(self, model: IRModel) -> str: for f in fields: line = self._field_line(f.name, f.type.annotation(), f, inherited) lines.append(" " + line + self.override_suppression(f)) + lines.extend(self.field_doc_lines(f)) return "\n".join(lines) @staticmethod diff --git a/src/unihttp_openapi_generator/render/serializers/base.py b/src/unihttp_openapi_generator/render/serializers/base.py index bc3c3d6..ea82566 100644 --- a/src/unihttp_openapi_generator/render/serializers/base.py +++ b/src/unihttp_openapi_generator/render/serializers/base.py @@ -154,6 +154,23 @@ def override_suppression(f: IRField) -> str: return "" return " # type: ignore[assignment, unused-ignore]" + @staticmethod + def field_doc_lines(f: IRField, indent: str = " ") -> list[str]: + """A field's schema prose as a PEP 258 attribute docstring. + + The same device the method classes already use for parameters and body fields, + and for the same reason: it is the one place the prose can land without + touching the constructor signature or the decoded value. It also renders + identically for all three serializers, where the native mechanisms do not -- + pydantic would need ``Field(description=...)`` on every documented field, + msgspec an ``Annotated[..., Meta(description=...)]``, and adaptix has no + equivalent at all. + """ + doc = docstring(f.description, indent) + if not doc: + return [] + return doc.rstrip("\n").split("\n") + # -- imports --------------------------------------------------------------- @abstractmethod diff --git a/src/unihttp_openapi_generator/render/serializers/msgspec.py b/src/unihttp_openapi_generator/render/serializers/msgspec.py index 91c6f5e..64f99f2 100644 --- a/src/unihttp_openapi_generator/render/serializers/msgspec.py +++ b/src/unihttp_openapi_generator/render/serializers/msgspec.py @@ -75,6 +75,7 @@ def render_model(self, model: IRModel) -> str: fields = sorted(model.fields, key=self._sort_key) for f in fields: lines.append(" " + self._field_line(f) + self.override_suppression(f)) + lines.extend(self.field_doc_lines(f)) if not fields and not doc: lines.append(" pass") return "\n".join(lines) diff --git a/src/unihttp_openapi_generator/render/serializers/pydantic.py b/src/unihttp_openapi_generator/render/serializers/pydantic.py index 905414c..084d6b0 100644 --- a/src/unihttp_openapi_generator/render/serializers/pydantic.py +++ b/src/unihttp_openapi_generator/render/serializers/pydantic.py @@ -162,6 +162,7 @@ def render_model(self, model: IRModel) -> str: lines.append(" pass") for f in model.fields: lines.append(" " + self._field_line(f) + self.override_suppression(f)) + lines.extend(self.field_doc_lines(f)) return "\n".join(lines) def _field_line(self, f: IRField) -> str: diff --git a/tests/test_render_models.py b/tests/test_render_models.py index 3935dea..cd50238 100644 --- a/tests/test_render_models.py +++ b/tests/test_render_models.py @@ -421,6 +421,54 @@ def test_only_hierarchy_members_become_kw_only() -> None: assert "@dataclass(kw_only=True)\nclass Button:" in source +_FIELD_PROSE_SPEC: dict[str, Any] = { + "openapi": "3.1.0", + "info": {"title": "S", "version": "1.0.0"}, + "paths": {}, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "integer", "description": "Server-assigned identifier."}, + "tag": {"type": "string"}, + "quote": {"type": "string", "description": 'He said "hi" with a \\ in it.'}, + }, + } + } + }, +} + + +@pytest.mark.parametrize("serializer", list(Serializer)) +def test_field_prose_renders_as_an_attribute_docstring( + serializer: Serializer, tmp_path: Path +) -> None: + """A property's ``description`` reaches the generated model, for every serializer. + + An attribute docstring is inert at runtime -- the class body just evaluates a + string -- so the model still constructs and decodes exactly as it did. + """ + source = _render(_FIELD_PROSE_SPEC, serializer) + lines = source.splitlines() + field_line = next(i for i, line in enumerate(lines) if line.strip().startswith("id: int")) + + assert lines[field_line + 1] == ' """Server-assigned identifier."""' + # prose with quotes and a backslash survives without breaking the docstring + assert ' r"""He said "hi" with a \\ in it."""' in lines + module = _load(source, tmp_path, f"genmodels_prose_{serializer.value}") + assert module.Pet(id=1).id == 1 + + +@pytest.mark.parametrize("serializer", list(Serializer)) +def test_field_without_prose_gets_no_docstring(serializer: Serializer) -> None: + lines = _render(_FIELD_PROSE_SPEC, serializer).splitlines() + tag_line = next(i for i, line in enumerate(lines) if line.strip().startswith("tag:")) + + assert not lines[tag_line + 1].strip().startswith('"""') + + def test_models_without_inheritance_keep_positional_dataclasses() -> None: ir = build_ir(_INHERITED_SPEC, RefResolver(_INHERITED_SPEC)) source = render_models_module(ir, get_strategy(Serializer.ADAPTIX)) From 8aeba77d8975ebce7ffbfbac9795d078f1c8f546 Mon Sep 17 00:00:00 2001 From: goduni <37146584+goduni@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:33:57 +0000 Subject: [PATCH 2/2] test: cover the rendered attribute docstring in the compile gate The unit tests import the rendered module, which proves an attribute docstring is inert, but nothing ran ruff or mypy over one: neither compile-gate fixture had a property with prose. The wrapping width the docstring helper targets is ruff's, so the one gate that would catch a regression was the one not looking. Give the sample spec a described property, long enough to wrap and carrying embedded quotes, so every gate combination -- three serializers, both file layouts, and the stubs pass -- checks the rendered prose. Also drop the unused ``indent`` parameter from ``field_doc_lines``: every strategy renders at the class-body indent, and nothing passed anything else. --- .../render/serializers/base.py | 6 ++++-- tests/conftest.py | 10 +++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/unihttp_openapi_generator/render/serializers/base.py b/src/unihttp_openapi_generator/render/serializers/base.py index ea82566..cc21e0a 100644 --- a/src/unihttp_openapi_generator/render/serializers/base.py +++ b/src/unihttp_openapi_generator/render/serializers/base.py @@ -155,7 +155,7 @@ def override_suppression(f: IRField) -> str: return " # type: ignore[assignment, unused-ignore]" @staticmethod - def field_doc_lines(f: IRField, indent: str = " ") -> list[str]: + def field_doc_lines(f: IRField) -> list[str]: """A field's schema prose as a PEP 258 attribute docstring. The same device the method classes already use for parameters and body fields, @@ -165,8 +165,10 @@ def field_doc_lines(f: IRField, indent: str = " ") -> list[str]: pydantic would need ``Field(description=...)`` on every documented field, msgspec an ``Annotated[..., Meta(description=...)]``, and adaptix has no equivalent at all. + + Rendered at the class-body indent, which is where every strategy puts it. """ - doc = docstring(f.description, indent) + doc = docstring(f.description, " ") if not doc: return [] return doc.rstrip("\n").split("\n") diff --git a/tests/conftest.py b/tests/conftest.py index d1435ca..d47d39c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -124,7 +124,15 @@ def sample_spec() -> dict[str, Any]: "required": ["id", "name"], "properties": { "id": {"type": "integer"}, - "name": {"type": "string"}, + # Prose long enough to wrap, so the compile gate checks the + # rendered attribute docstring against ruff's default width. + "name": { + "type": "string", + "description": ( + "The pet's display name, as the shelter recorded it " + 'when the animal was taken in. May contain "quotes".' + ), + }, "status": {"type": "string", "enum": ["available", "sold"]}, "createdAt": {"type": "string", "format": "date-time"}, "tag": {"type": ["string", "null"]},