Skip to content

Commit 4f9fc60

Browse files
author
Anders Brams
committed
feat: docstrings
1 parent 461ec22 commit 4f9fc60

9 files changed

Lines changed: 123 additions & 2 deletions

File tree

openapi_python/generator/model.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@ class FieldDef:
5656
name: str
5757
annotation: TypeAnnotation
5858
required: bool
59+
description: str | None = None
5960

6061

6162
@dataclass(frozen=True)
6263
class TypedDictDef:
6364
name: str
6465
fields: tuple[FieldDef, ...]
66+
description: str | None = None
6567

6668

6769
@dataclass(frozen=True)

openapi_python/generator/normalize.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,11 +363,19 @@ def _schema_object_to_type(
363363
name=prop_name,
364364
annotation=field_type,
365365
required=prop_name in required,
366+
description=safe_get(prop_schema, "description", type=str),
366367
)
367368
)
368369

369370
state = _without_processing(state, name)
370-
state = _with_typeddict(state, TypedDictDef(name=name, fields=tuple(fields)))
371+
state = _with_typeddict(
372+
state,
373+
TypedDictDef(
374+
name=name,
375+
fields=tuple(fields),
376+
description=safe_get(schema, "description", type=str),
377+
),
378+
)
371379
return _nullable(NamedAnnotation(name), nullable), state
372380

373381

openapi_python/generator/render.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ def _class_field_annotation(field: FieldDef, total_optional: bool) -> str:
6161
return annotation
6262

6363

64+
def _string_literal(value: str) -> str:
65+
return repr(value)
66+
67+
68+
def _comment(value: str, spaces: int = 0) -> str:
69+
prefix = " " * spaces
70+
return "\n".join(
71+
f"{prefix}# {line}" if line else f"{prefix}#" for line in value.splitlines()
72+
)
73+
74+
6475
def _supports_typeddict_class_syntax(defn: TypedDictDef) -> bool:
6576
return all(
6677
field.name.isidentifier()
@@ -81,6 +92,8 @@ def _supports_typeddict_class_syntax(defn: TypedDictDef) -> bool:
8192
_JINJA_ENV.filters["annotation"] = _render_annotation
8293
_JINJA_ENV.filters["field_annotation"] = _field_annotation
8394
_JINJA_ENV.filters["class_field_annotation"] = _class_field_annotation
95+
_JINJA_ENV.filters["comment"] = _comment
96+
_JINJA_ENV.filters["string_literal"] = _string_literal
8497

8598

8699
def _render_template(name: str, **context: object) -> str:
Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
{% if class_syntax -%}
22
class {{ defn.name }}(TypedDict{% if total_optional %}, total=False{% endif %}):
3-
{% if not defn.fields %}
3+
{% if defn.description %}
4+
{{ defn.description | string_literal }}
5+
{% endif %}
6+
{% if not defn.fields and not defn.description %}
47
pass
58
{% else %}
69
{% for field in defn.fields %}
710
{{ field.name }}: {{ field | class_field_annotation(total_optional) }}
11+
{% if field.description %}
12+
{{ field.description | string_literal }}
13+
{% endif %}
814
{% endfor %}
915
{% endif %}
1016
{% elif not defn.fields -%}
1117
{{ defn.name }} = TypedDict({{ defn.name | repr }}, {})
18+
{% if defn.description %}
19+
{{ defn.name }}.__doc__ = {{ defn.description | string_literal }}
20+
{% endif %}
1221
{% else -%}
1322
{{ defn.name }} = TypedDict(
1423
{{ defn.name | repr }},
1524
{
1625
{% for field in defn.fields %}
26+
{% if field.description %}
27+
{{ field.description | comment(8) }}
28+
{% endif %}
1729
{{ field.name | repr }}: {{ field | field_annotation }},
1830
{% endfor %}
1931
},
2032
)
33+
{% if defn.description %}
34+
{{ defn.name }}.__doc__ = {{ defn.description | string_literal }}
35+
{% endif %}
2136
{% endif -%}

tests/contract/docstrings/app.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from __future__ import annotations
2+
3+
from fastapi import FastAPI
4+
from pydantic import BaseModel, Field
5+
6+
app = FastAPI()
7+
8+
9+
class MyDTO(BaseModel):
10+
"""This is a descriptive docstring"""
11+
12+
a: int = Field(description="This is the docstring for a single field")
13+
14+
15+
@app.get("/dto", response_model=MyDTO)
16+
def get_dto() -> MyDTO:
17+
return MyDTO(a=1)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from __future__ import annotations
2+
3+
import ast
4+
import importlib
5+
import json
6+
from pathlib import Path
7+
8+
from app import app
9+
10+
from openapi_python.generator import GenerationRequest, generate_client
11+
12+
13+
def main() -> None:
14+
output_dir = Path(__file__).parent / "generated"
15+
generate_client(
16+
GenerationRequest(
17+
output_dir=output_dir,
18+
spec_json=json.dumps(app.openapi()),
19+
overwrite=True,
20+
)
21+
)
22+
23+
source = (output_dir / "my_client" / "types.py").read_text()
24+
module = ast.parse(source)
25+
dto = next(
26+
node
27+
for node in module.body
28+
if isinstance(node, ast.ClassDef) and node.name == "MyDTO"
29+
)
30+
generated_types = importlib.import_module("generated.my_client.types")
31+
32+
assert ast.get_docstring(dto) == "This is a descriptive docstring"
33+
assert generated_types.MyDTO.__doc__ == "This is a descriptive docstring"
34+
assert "This is the docstring for a single field" in source
35+
36+
37+
if __name__ == "__main__":
38+
main()
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from __future__ import annotations
2+
3+
from typing import assert_type
4+
5+
from generated.my_client import AsyncClient
6+
from generated.my_client.types import MyDTO
7+
8+
client = AsyncClient(base_url="http://testserver")
9+
10+
11+
async def main() -> None:
12+
result = await client.get("/dto")()
13+
assert_type(result, MyDTO)
14+
assert_type(result["a"], int)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from __future__ import annotations
2+
3+
from typing import assert_type
4+
5+
from generated.my_client import Client
6+
from generated.my_client.types import MyDTO
7+
8+
client = Client(base_url="http://testserver")
9+
10+
result = client.get("/dto")()
11+
assert_type(result, MyDTO)
12+
assert_type(result["a"], int)

tests/contract/enum_literal/usage_sync.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
client = Client(base_url="http://testserver")
1515

16+
res = client.get("/articles/{article_id}")(params={"article_id": 1})
17+
1618
article = client.get("/articles/{article_id}")(params={"article_id": 1})
1719
assert_type(article, Article)
1820
assert_type(article["status"], Status)

0 commit comments

Comments
 (0)