Skip to content

Commit 5f3b8ce

Browse files
authored
eng: add option to put docstrings on model attributes BNCH-114718 (#225)
* eng: add option to put docstrings on model attributes BNCH-114718 * typo in comments * also move docstrings to attributes in client classes
1 parent 082fd18 commit 5f3b8ce

File tree

7 files changed

+111
-20
lines changed

7 files changed

+111
-20
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ class_overrides:
100100
101101
The easiest way to find what needs to be overridden is probably to generate your client and go look at everything in the `models` folder.
102102

103+
### docstrings_on_attributes
104+
105+
By default, when `openapi-python-client` generates a model class, it includes a list of attributes and their
106+
descriptions in the docstring for the class. If you set this option to `true`, then the attribute descriptions
107+
will be put in docstrings for the attributes themselves, and will not be in the class docstring.
108+
109+
```yaml
110+
docstrings_on_attributes: true
111+
```
112+
103113
### literal_enums
104114

105115
By default, `openapi-python-client` generates classes inheriting for `Enum` for enums. It can instead use `Literal`

end_to_end_tests/functional_tests/generated_code_execution/test_docstrings.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import re
12
from typing import Any, List
23

34
from end_to_end_tests.functional_tests.helpers import (
45
with_generated_code_import,
56
with_generated_client_fixture,
67
)
8+
from end_to_end_tests.generated_client import GeneratedClientContext
79

810

911
class DocstringParser:
@@ -36,18 +38,57 @@ def get_section(self, header_line: str) -> List[str]:
3638
required: ["reqStr", "undescribedProp"]
3739
""")
3840
@with_generated_code_import(".models.MyModel")
39-
class TestSchemaDocstrings:
41+
class TestSchemaDocstringsDefaultBehavior:
4042
def test_model_description(self, MyModel):
4143
assert DocstringParser(MyModel).lines[0] == "I like this type."
4244

43-
def test_model_properties(self, MyModel):
45+
def test_model_properties_in_model_description(self, MyModel):
4446
assert set(DocstringParser(MyModel).get_section("Attributes:")) == {
4547
"req_str (str): This is necessary.",
4648
"opt_str (Union[Unset, str]): This isn't necessary.",
4749
"undescribed_prop (str):",
4850
}
4951

5052

53+
@with_generated_client_fixture(
54+
"""
55+
components:
56+
schemas:
57+
MyModel:
58+
description: I like this type.
59+
type: object
60+
properties:
61+
prop1:
62+
type: string
63+
description: This attribute has a description
64+
prop2:
65+
type: string # no description for this one
66+
required: ["prop1", "prop2"]
67+
""",
68+
config="docstrings_on_attributes: true",
69+
)
70+
@with_generated_code_import(".models.MyModel")
71+
class TestSchemaWithDocstringsOnAttributesOption:
72+
def test_model_description_is_entire_docstring(self, MyModel):
73+
assert MyModel.__doc__.strip() == "I like this type."
74+
75+
def test_attrs_have_docstrings(self, generated_client: GeneratedClientContext):
76+
# A docstring that appears after an attribute is *not* stored in __doc__ anywhere
77+
# by the interpreter, so we can't inspect it that way-- but it's still valid for it
78+
# to appear there, and it will be recognized by documentation tools. So we'll assert
79+
# that these strings appear in the source code. The code should look like this:
80+
# class MyModel:
81+
# """I like this type."""
82+
# prop1: str
83+
# """This attribute has a description"""
84+
# prop2: str
85+
#
86+
source_file_path = generated_client.output_path / generated_client.base_module / "models" / "my_model.py"
87+
content = source_file_path.read_text()
88+
assert re.search('\n *prop1: *str\n *""" *This attribute has a description *"""\n', content)
89+
assert re.search('\n *prop2: *str\n *[^"]', content)
90+
91+
5192
@with_generated_client_fixture(
5293
"""
5394
tags:

openapi_python_client/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def __init__(
9595

9696
self.env.filters.update(TEMPLATE_FILTERS)
9797
self.env.globals.update(
98+
config=config,
9899
utils=utils,
99100
python_identifier=lambda x: utils.PythonIdentifier(x, config.field_prefix),
100101
class_name=lambda x: utils.ClassName(x, config.field_prefix),

openapi_python_client/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class ConfigFile(BaseModel):
4141
package_version_override: Optional[str] = None
4242
use_path_prefixes_for_title_model_names: bool = True
4343
post_hooks: Optional[list[str]] = None
44+
docstrings_on_attributes: bool = False
4445
field_prefix: str = "field_"
4546
generate_all_tags: bool = False
4647
http_timeout: int = 5
@@ -70,6 +71,7 @@ class Config:
7071
package_version_override: Optional[str]
7172
use_path_prefixes_for_title_model_names: bool
7273
post_hooks: list[str]
74+
docstrings_on_attributes: bool
7375
field_prefix: str
7476
generate_all_tags: bool
7577
http_timeout: int
@@ -111,6 +113,7 @@ def from_sources(
111113
package_version_override=config_file.package_version_override,
112114
use_path_prefixes_for_title_model_names=config_file.use_path_prefixes_for_title_model_names,
113115
post_hooks=post_hooks,
116+
docstrings_on_attributes=config_file.docstrings_on_attributes,
114117
field_prefix=config_file.field_prefix,
115118
generate_all_tags=config_file.generate_all_tags,
116119
http_timeout=config_file.http_timeout,

openapi_python_client/templates/client.py.jinja

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ from attrs import define, field, evolve
55
import httpx
66

77

8+
{% set attrs_info = {
9+
"raise_on_unexpected_status": namespace(
10+
type="bool",
11+
default="field(default=False, kw_only=True)",
12+
docstring="Whether or not to raise an errors.UnexpectedStatus if the API returns a status code"
13+
" that was not documented in the source OpenAPI document. Can also be provided as a keyword"
14+
" argument to the constructor."
15+
),
16+
"token": namespace(type="str", default="", docstring="The token to use for authentication"),
17+
"prefix": namespace(type="str", default='"Bearer"', docstring="The prefix to use for the Authorization header"),
18+
"auth_header_name": namespace(type="str", default='"Authorization"', docstring="The name of the Authorization header"),
19+
} %}
20+
21+
{% macro attr_in_class_docstring(name) %}
22+
{{ name }}: {{ attrs_info[name].docstring }}
23+
{%- endmacro %}
24+
25+
{% macro declare_attr(name) %}
26+
{% set attr = attrs_info[name] %}
27+
{{ name }}: {{ attr.type }}{% if attr.default %} = {{ attr.default }}{% endif %}
28+
{% if attr.docstring and config.docstrings_on_attributes +%}
29+
"""{{ attr.docstring }}"""
30+
{%- endif %}
31+
{% endmacro %}
32+
833
@define
934
class Client:
1035
"""A class for keeping track of data related to the API
@@ -29,14 +54,14 @@ class Client:
2954
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
3055
{% endmacro %}
3156
{{ httpx_args_docstring() }}
57+
{% if not config.docstrings_on_attributes %}
3258

3359
Attributes:
34-
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
35-
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
36-
argument to the constructor.
60+
{{ attr_in_class_docstring("raise_on_unexpected_status") | wordwrap(101) | indent(12) }}
61+
{% endif %}
3762
"""
3863
{% macro attributes() %}
39-
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
64+
{{ declare_attr("raise_on_unexpected_status") | indent(4) }}
4065
_base_url: str = field(alias="base_url")
4166
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
4267
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
@@ -147,20 +172,20 @@ class AuthenticatedClient:
147172
"""A Client which has been authenticated for use on secured endpoints
148173

149174
{{ httpx_args_docstring() }}
175+
{% if not config.docstrings_on_attributes %}
150176

151177
Attributes:
152-
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
153-
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
154-
argument to the constructor.
155-
token: The token to use for authentication
156-
prefix: The prefix to use for the Authorization header
157-
auth_header_name: The name of the Authorization header
178+
{{ attr_in_class_docstring("raise_on_unexpected_status") | wordwrap(101) | indent(12) }}
179+
{{ attr_in_class_docstring("token") | indent(8) }}
180+
{{ attr_in_class_docstring("prefix") | indent(8) }}
181+
{{ attr_in_class_docstring("auth_header_name") | indent(8) }}
182+
{% endif %}
158183
"""
159184

160185
{{ attributes() }}
161-
token: str
162-
prefix: str = "Bearer"
163-
auth_header_name: str = "Authorization"
186+
{{ declare_attr("token") | indent(4) }}
187+
{{ declare_attr("prefix") | indent(4) }}
188+
{{ declare_attr("auth_header_name") | indent(4) }}
164189

165190
{{ builders("AuthenticatedClient") }}
166191
{{ httpx_stuff("AuthenticatedClient", "self._headers[self.auth_header_name] = f\"{self.prefix} {self.token}\" if self.prefix else self.token") }}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
{% macro safe_docstring(content) %}
1+
{% macro safe_docstring(content, omit_if_empty=False) %}
22
{# This macro returns the provided content as a docstring, set to a raw string if it contains a backslash #}
3+
{% if (not omit_if_empty) or (content | trim) %}
34
{% if '\\' in content -%}
45
r""" {{ content }} """
56
{%- else -%}
67
""" {{ content }} """
78
{%- endif -%}
9+
{% endif %}
810
{% endmacro %}

openapi_python_client/templates/model.py.jinja

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,25 +47,34 @@ T = TypeVar("T", bound="{{ class_name }}")
4747
{{ model.example | string | wordwrap(112) | indent(12) }}
4848

4949
{% endif %}
50-
{% if model.required_properties or model.optional_properties %}
50+
{% if (not config.docstrings_on_attributes) and (model.required_properties or model.optional_properties) %}
5151
Attributes:
5252
{% for property in model.required_properties + model.optional_properties %}
5353
{{ property.to_docstring() | wordwrap(112) | indent(12) }}
5454
{% endfor %}{% endif %}
5555
{% endmacro %}
5656

57+
{% macro declare_property(property) %}
58+
{%- if config.docstrings_on_attributes and property.description -%}
59+
{{ property.to_string() }}
60+
{{ safe_docstring(property.description, omit_if_empty=True) | wordwrap(112) }}
61+
{%- else -%}
62+
{{ property.to_string() }}
63+
{%- endif -%}
64+
{% endmacro %}
65+
5766
@_attrs_define
5867
class {{ class_name }}:
59-
{{ safe_docstring(class_docstring_content(model)) | indent(4) }}
68+
{{ safe_docstring(class_docstring_content(model), omit_if_empty=config.docstrings_on_attributes) | indent(4) }}
6069

6170
{% for property in model.required_properties + model.optional_properties %}
6271
{% if property.default is none and property.required %}
63-
{{ property.to_string() }}
72+
{{ declare_property(property) | indent(4) }}
6473
{% endif %}
6574
{% endfor %}
6675
{% for property in model.required_properties + model.optional_properties %}
6776
{% if property.default is not none or not property.required %}
68-
{{ property.to_string() }}
77+
{{ declare_property(property) | indent(4) }}
6978
{% endif %}
7079
{% endfor %}
7180
{% if model.additional_properties %}

0 commit comments

Comments
 (0)