Your Python client’s docstrings were written from the spec once, by hand, and the spec has moved
twice since. help(client.get_meter) still describes a parameter that no longer exists, the IDE
tooltip agrees with it, and the one place the truth lives is a YAML file nobody opens from Python.
Nothing fails, because nothing compares the two.
What you get
You will end up with a generated client, a generated dataclass module, and a report that says which of the document’s descriptions arrived, which are empty, and which only repeat the name. This is for you if you publish a Python client for an API you describe in OpenAPI.
Short answer
Run openapi-python-client for the operations and datamodel-code-generator for plain
dataclasses, both from the same OpenAPI document, so help() and the IDE tooltip carry the text the document
carries. Then read the output before shipping it: parameter descriptions are taken from the
parameter’s schema, not the parameter, and a description that repeats the name is copied
faithfully into every docstring.
You will need
Python 3.12, an OpenAPI 3 document with description fields, and a docstring style. The generated
code and the hand-written comparison here use the
Google style,
because both generators emit its Args: and Attributes: sections. The
numpydoc format, the other common
choice, is not used here because neither generator writes it. Verified 2026-09-25 against Python 3.12.3,
openapi-python-client 0.29.1, datamodel-code-generator 0.83.0 and ruff 0.16.9. The runtime hints
follow PEP 604, so a nullable string is str | None.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
datamodel-code-generator | You want the models as dataclasses, Pydantic, or TypedDict, with the schema text on them | Models only, no operations, and attribute docstrings that help() never shows | You need the request functions generated too |
| Hand-written docstrings | A small client, and prose that explains rather than restates the field | Every rename in the document is an edit nobody is reminded to make | The document changes more than once a quarter |
openapi-python-client | A whole client, one function per operation, docstrings and hints from the document | Descriptions read like the document, empty Args entries where the document put them elsewhere, and httpx plus attrs at runtime | You cannot take the runtime dependencies, or the prose has to read well |
The generators win on currency and lose on prose, and they lose in a specific way. A generated
docstring is exactly as good as the sentence in the document, so a document that says The id.
produces a client that says The id. fifty times. The hand-written client reads better on the
day it is written and gives you no signal when it stops being true.
Generate once and read what came out
Both generators run from one script, into a generated/ directory the checks below read with
the standard library alone. The script pins the versions and puts the virtual environment first on
the PATH, because openapi-python-client runs ruff after generating and takes whichever copy it
finds first.
PATH="$VENV/bin:$PATH" "$VENV/bin/openapi-python-client" generate \
--path openapi.json --output-path generated/opc --meta none --overwrite
rm -rf generated/opc/.ruff_cache
"$VENV/bin/datamodel-codegen" \
--input openapi.json --input-file-type openapi \
--output generated/models.py \
--output-model-type dataclasses.dataclass \
--use-schema-description --use-field-description \
--target-python-version 3.12 --formatters builtin --disable-timestamp
The document has three operations and five schemas. Two of its descriptions are written to be useful, and two are written the way most descriptions are.
{ "name": "site", "in": "query", "required": false, "description": "Only meters installed at this site, such as hq or depot.", "schema": { "type": "string" } },
{ "name": "limit", "in": "query", "required": false, "description": "The limit.", "schema": { "type": "integer", "default": 50, "minimum": 1, "maximum": 200 } }
datamodel-code-generator turns each schema into a dataclass with the schema description as the
class docstring and each property description as a string literal under its field.
@dataclass
class Meter:
"""
A physical meter and where it is installed.
"""
id: str
"""
The id.
"""
site: str
"""
Name of the site the meter is installed at.
"""
kind: Kind
"""
What the meter measures.
"""
decommissioned_at: str | None
"""
When the meter was taken out of service, or null while it is still in service.
"""
installed_at: str | None = None
"""
When the meter was installed. Absent for meters imported from the legacy system.
"""
Read the last two fields together. decommissioned_at is required and nullable in the document,
installed_at is optional, and the dataclass gives both the same hint and differs only in the
default. openapi-python-client keeps the distinction, with datetime.datetime | None for the
first and datetime.datetime | Unset = UNSET for the second, at the cost of a sentinel type your
callers have to learn.
Put the description where the generator reads it
Every parameter entry in the generated Args: sections came out without a description. The
document describes site and limit on the
Parameter Object, which is where
the specification puts a parameter’s description. openapi-python-client 0.29.1 reads the
description from the parameter’s schema instead. Move the sentence into the schema and it
appears; leave it where the specification says and the tooltip shows site (str | Unset): and
nothing after the colon. That was measured by generating twice, once with each placement.
python3 help_demo.py
== hand-written: help(MetersClient.get_meter)
Python Library Documentation: function get_meter in module meters_client
get_meter(self, meter_id: 'str') -> 'Meter'
Load one meter by id.
Args:
meter_id: The identifier from a previous ``list_meters`` call or
from the meter's label, such as ``mtr_01HZX4``.
Returns:
The meter, including ``decommissioned_at`` when it has been
taken out of service.
Raises:
ApiError: If no meter has that id (status 404), or on any other
error document.
== datamodel-codegen: help(models.Meter), first lines
Python Library Documentation: class Meter in module models
class Meter(builtins.object)
| Meter(id: 'str', site: 'str', kind: 'Kind', decommissioned_at: 'str | None', installed_at: 'str | None' = None) -> None
|
| A physical meter and where it is installed.
|
| Methods defined here:
== openapi-python-client: generated/opc/api/default/get_meter.py, sync
sync(meter_id: str, *, client: AuthenticatedClient | Client) -> Error | Meter | None
Load one meter
Loads one meter by id, including the date it was decommissioned if it has been.
Args:
meter_id (str):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Error | Meter
Three things in that output are the whole comparison. The hand-written entry explains where a
meter_id comes from, which no document field says. The dataclass shows its class docstring and
not one of the five attribute descriptions, because help()
renders what pydoc finds in __doc__, and a
string literal under a field is not in __doc__.1 The generated function carries the
operation’s summary and description and an Args: entry with nothing in it.
The third block was read from the source with ast
rather than imported, because the generated module imports httpx and this page’s checks run
with the standard library only. help() prints the same docstring under the same signature.
Count what the docstrings say
docstrings.py parses each file with ast, reads the Args: and Attributes: sections, and
gives every parameter one of four verdicts. The one worth automating is the third.
def repeats_the_name(name: str, description: str) -> bool:
"""True when a description is made only of the parameter's own words."""
words = {w for w in re.findall(r"[a-z0-9]+", description.lower())} - FILLER
return bool(words) and words <= set(name.lower().split("_"))
python3 docstrings.py
generated with datamodel-code-generator 0.83.0, openapi-python-client 0.29.1, ruff 0.16.9
file params described empty name only undocumented attr docstrings
generated/opc/api/default/get_meter.py 8 0 4 0 4 0
generated/opc/api/default/list_meters.py 12 0 8 0 4 0
generated/opc/api/default/record_reading.py 12 4 4 0 4 0
generated/opc/models/meter.py 6 4 0 1 1 0
generated/opc/models/reading.py 6 3 0 2 1 0
generated/models.py 17 14 0 3 0 17
meters_client.py 12 12 0 0 0 0
empty: asyncio.limit, asyncio.meter_id, asyncio.site, asyncio_detailed.limit, asyncio_detailed.meter_id, asyncio_detailed.site, sync.limit, sync.meter_id, sync.site, sync_detailed.limit, sync_detailed.meter_id, sync_detailed.site
name only: Meter.id, Reading.id, Reading.meter_id
undocumented: asyncio.client, asyncio_detailed.client, from_dict.src_dict, sync.client, sync_detailed.client
openapi.json descriptions that only repeat the name: Meter.id, Reading.id, Reading.meter_id, list_meters(limit), get_meter(meter_id), record_reading(meter_id)
The last line is the one to act on. Every generated file downstream repeats the six descriptions
in the document that add nothing to the name they describe. The meter id. is not
documentation, it is the name with spaces in it, and a generator cannot tell the difference. Fix
the sentence in the document and both generators pick it up on the next run; fix it in the output
and the next run puts it back. The check follows local $refs and shared path parameters,
so it reads your document too.
record_reading scores four described parameters because its body properties carry real
sentences in the schema, which is where the generator looks. limit is empty because its
docstring entry reads Default: 50. and nothing else, which the report treats as no description.
Lint the docstrings, not the prose
ruff carries the pydocstyle rules, and its
convention setting turns
off the rules that fight the chosen style. This configuration is the whole of it.
target-version = "py312"
[lint]
select = ["D"]
[lint.pydocstyle]
convention = "google"
The file is not named ruff.toml on purpose. openapi-python-client runs ruff check --fix-only
and ruff format on its own output, ruff discovers the nearest configuration file, and a
ruff.toml in the parent directory changed the generated code. The config is passed with
--config pydocstyle.toml instead. ruff is not installed on the runner that checks this page, so
its findings are recorded here rather than captured. On the three operation modules, 39 findings.
Twelve are D417, for client, which
has no entry, and the empty site or meter_id entry. Another 12 are D415 because the summaries
end without a period, 12 are D202 for the blank line after each docstring, and 3 are D100 for
the missing module docstring.2 On generated/models.py, 12 findings, ten about the shape of
the triple-quoted blocks and two for missing docstrings. On meters_client.py, none.
D417 misses limit. Its entry reads Default: 50., which is text, so the rule is satisfied and
the report is not. A linter checks that something was written, and only a reader checks
that it says anything.
Check it worked
Thirteen tests pin the verdicts and exercise the hand-written client against a fake opener and a server that never answers. A regeneration with a different generator version fails here rather than in a tooltip.
def test_parameter_descriptions_do_not_reach_the_args_section(self):
rep = report(Path("generated/opc/api/default/get_meter.py"))
empty = {f"{f.where}.{f.name}" for f in rep.findings if f.verdict == "empty"}
self.assertEqual(empty, {"sync.meter_id", "sync_detailed.meter_id", "asyncio.meter_id", "asyncio_detailed.meter_id"})
python3 test_docstrings.py
test_model_attributes_carry_the_schema_descriptions_noise_included (__main__.GeneratedClient.test_model_attributes_carry_the_schema_descriptions_noise_included) ... ok
test_parameter_descriptions_do_not_reach_the_args_section (__main__.GeneratedClient.test_parameter_descriptions_do_not_reach_the_args_section) ... ok
test_dataclasses_carry_attribute_docstrings_that_help_does_not_show (__main__.GeneratedModels.test_dataclasses_carry_attribute_docstrings_that_help_does_not_show) ... ok
test_every_parameter_is_described (__main__.HandWritten.test_every_parameter_is_described) ... ok
test_a_server_that_never_answers_is_given_up_on (__main__.HandWrittenClient.test_a_server_that_never_answers_is_given_up_on) ... ok
test_a_slash_in_a_meter_id_stays_inside_one_path_segment (__main__.HandWrittenClient.test_a_slash_in_a_meter_id_stays_inside_one_path_segment) ... ok
test_absent_and_null_both_become_none_and_a_reading_is_posted (__main__.HandWrittenClient.test_absent_and_null_both_become_none_and_a_reading_is_posted) ... ok
test_an_error_document_and_a_bare_error_both_raise_api_error (__main__.HandWrittenClient.test_an_error_document_and_a_bare_error_both_raise_api_error) ... ok
test_a_description_made_of_the_name_is_noise (__main__.RepeatsTheName.test_a_description_made_of_the_name_is_noise) ... ok
test_a_description_with_a_new_word_is_not (__main__.RepeatsTheName.test_a_description_with_a_new_word_is_not) ... ok
test_an_empty_description_is_not_noise_either (__main__.RepeatsTheName.test_an_empty_description_is_not_noise_either) ... ok
test_the_noise_starts_in_the_document (__main__.Spec.test_the_noise_starts_in_the_document) ... ok
test_the_report_reads_shared_and_referenced_parameters (__main__.Spec.test_the_report_reads_shared_and_referenced_parameters) ... ok
The test that matters most is the first. It asserts the empty entries are exactly the four path parameters. A generator release that starts reading the Parameter Object description turns it red, and the page gets re-verified instead of staying wrong.
When it goes wrong
Every Args: entry is empty. The descriptions sit on the Parameter Object and the generator reads
the schema. Copy each sentence into the parameter’s schema, which the specification allows, or
accept empty entries for path and query parameters until the generator changes.
The dataclass hints say str | None for a field that is never null. installed_at is optional,
not nullable, and the datamodel-code-generator dataclass output spells both the same way with a
default of None. If the difference matters to callers, use the openapi-python-client models,
whose Unset sentinel keeps it, or write the distinction into the field’s description.
A hand edit to a docstring disappears. Both generators overwrite their output on every run, and
openapi-python-client refuses to write into an existing directory without --overwrite, which
is the last warning you get. Edit the document, or use the generator’s custom-template-path
option, and keep the output directory out of the places people edit.
The document’s descriptions are one word each. The report’s last line names them. Rewrite those before regenerating, because a generator multiplies a bad sentence by the number of places it is used.
When not to do this
Do not generate docstrings from a document whose descriptions were never written for a reader.
A schema that says The id. on every id produces a client that says it on every model and every
function, and the generated prose is then a liability rather than an asset. Write the sentences in
the document first, which is where they belong anyway, and run the report on the document before
running the generators.
Do not hand-write docstrings for a client of an API that changes monthly. The hand-written
get_meter on this page reads better than either generated one, and every rename in the document is a
silent edit somebody has to remember. The comparison holds only while someone is looking.
Do not ship the datamodel-code-generator attribute docstrings as the only documentation of a field.
They are a convention that editors read and help() does not, so a caller at a REPL sees the
class docstring and nothing about the fields.
Do not treat a green pydocstyle run as evidence the docstrings say anything. D417 accepts
Default: 50. as a description.
Related how-tos
Last verified
Verified 2026-09-25 against Python 3.12.3, openapi-python-client 0.29.1,
datamodel-code-generator 0.83.0 and ruff 0.16.9. Every output block is what the command preceding it printed, run in the
page’s code directory with the standard library. The generation itself is not repeated by the
checks: generate.sh was run once, twice in fact, to confirm that two runs produce the same bytes,
and its output is committed under generated/. The ruff figures are from a local run and are
quoted, not captured.
Footnotes
-
PEP 257 names the convention: a string literal directly after a simple assignment at the top level of a module, class, or
__init__is an “attribute docstring”. The PEP says in the same breath that such strings are not accessible as runtime attributes and may be extracted by software tools. The proposal to make them real, PEP 224, was written in August 2000 for Python 2.1 and rejected. The name survived the rejection, which is how a docstring came to exist thathelp()has never read. ↩︎ Back to text -
The ruff documentation’s own example of the
conventionsetting selects everyDrule, sets the Google convention, and then ignores D417. The comment beside it explains that the rule requires documentation for every function parameter. The one rule that would have noticed the empty entries on this page is the one the worked example shows how to switch off. ↩︎ Back to text