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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ class TimeSeriesRates:
start_date: date
end_date: date
rates: dict[str, dict[str, float]]
"""ISO date -> (currency code -> rate)."""
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ class ListComments(BaseMethod[list[Comment]]):
__method__ = "GET"

post_id: Query[Omittable[int]] = Omitted()
"""Only comments on this post."""
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ class DeletePost(BaseMethod[dict[str, Any]]):
__method__ = "DELETE"

id: Path[int]
"""Resource id."""
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ class GetPost(BaseMethod[Post]):
__method__ = "GET"

id: Path[int]
"""Resource id."""
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ class GetPostComments(BaseMethod[list[Comment]]):
__method__ = "GET"

id: Path[int]
"""Resource id."""
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ class ListPosts(BaseMethod[list[Post]]):
__method__ = "GET"

user_id: Query[Omittable[int]] = Omitted()
"""Only posts by this user."""
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ class GetTodo(BaseMethod[Todo]):
__method__ = "GET"

id: Path[int]
"""Resource id."""
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ class ListTodos(BaseMethod[list[Todo]]):
__method__ = "GET"

user_id: Query[Omittable[int]] = Omitted()
"""Only todos for this user."""
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ class GetUser(BaseMethod[User]):
__method__ = "GET"

id: Path[int]
"""Resource id."""
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/unihttp_openapi_generator/render/serializers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]},
Expand Down
48 changes: 48 additions & 0 deletions tests/test_render_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading