How-to › Document and support developers

How to generate Python docstrings and type hints from an API spec#

Generate a Python client whose help() text and type hints come from the OpenAPI document, then measure which descriptions survived, are empty, or repeat the name.

Audience
API producer
Level
intermediate
Topic
Ship docs with the code
Languages
Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
datamodel-code-generatorYou want the models as dataclasses, Pydantic, or TypedDict, with the schema text on themModels only, no operations, and attribute docstrings that help() never showsYou need the request functions generated too
Hand-written docstringsA small client, and prose that explains rather than restates the fieldEvery rename in the document is an edit nobody is reminded to makeThe document changes more than once a quarter
openapi-python-clientA whole client, one function per operation, docstrings and hints from the documentDescriptions read like the document, empty Args entries where the document put them elsewhere, and httpx plus attrs at runtimeYou 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.

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

  1. 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 that help() has never read. ↩︎ Back to text

  2. The ruff documentation’s own example of the convention setting selects every D rule, 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.