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..cc21e0a 100644 --- a/src/unihttp_openapi_generator/render/serializers/base.py +++ b/src/unihttp_openapi_generator/render/serializers/base.py @@ -154,6 +154,25 @@ def override_suppression(f: IRField) -> str: return "" return " # type: ignore[assignment, unused-ignore]" + @staticmethod + 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, + 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. + + Rendered at the class-body indent, which is where every strategy puts it. + """ + doc = docstring(f.description, " ") + 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/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"]}, 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))