๐ ๏ธ Development¶
Install the package in editable mode:
$ git clone [email protected]:datamodel-code-generator/datamodel-code-generator.git
$ pip install -e datamodel-code-generator
๐ค Contribute¶
We are waiting for your contributions to datamodel-code-generator.
๐ How to contribute¶
## 1. Clone your fork repository
$ git clone [email protected]:<your username>/datamodel-code-generator.git
$ cd datamodel-code-generator
## 2. Install [uv](https://docs.astral.sh/uv/getting-started/installation/)
$ curl -LsSf https://astral.sh/uv/install.sh | sh
## 3. Install tox with uv
$ uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv
## 4. Create developer environment
$ tox run -e dev
.tox/dev is a Python environment you can use for development purposes
## 5. Install pre-commit hooks
$ uv tool install prek
$ prek install
## 6. Create new branch and rewrite code.
$ git checkout -b new-branch
## 7. Run unittest under Python 3.13 (you should pass all test and coverage should be 100%)
$ tox run -e 3.13
## 8. Format and lint code (will print errors that cannot be automatically fixed)
$ tox run -e fix
## 9. Check generated docs, diagrams, and playground assets are up to date
$ tox run -e docs-check-all -- --check
## 10. Commit and Push...
๐งช E2E test assertions¶
E2E tests that validate generated output must use the shared assertion helpers
instead of direct assert statements. The helpers compare complete generated
files, generated modules, warnings, errors, and HTTP request behavior with
consistent diffs and update hints.
Use helpers such as run_main_and_assert, run_main_url_and_assert,
create_assert_file_content, assert_output, assert_directory_content,
assert_generated_modules_output, assert_generated_file_matches_output,
assert_httpx_get_kwargs, and assert_warnings_contain where they fit. Prefer
full expected-file or inline snapshot comparisons for generated output over
substring checks.
Tests do not need to name expected files. Write the test with the helpers (input
and arguments only), run pytest '<test>', and commit every file it writes:
tests/data/expected/** and the .refs files, which can include those of other
modules that share output with it; git add -A tests stages them all and the
run lists them. To accept changed
output, run pytest '<test>' --inline-snapshot=fix. Never edit .refs or
expected files by hand; every failure prints the exact command that fixes it.
Review the resulting git diff before committing.
Each test module has a .refs file next to it that records, per test, which
stored expected output it compares. pytest writes it, starting with a
# Generated by pytest. Do not edit by hand header that local runs restore and
CI requires. Plain local runs create
missing expected output (the [tool.inline-snapshot] default flags in
pyproject.toml); pytest -n runs only rewrite .refs files into the form
pytest writes (sorted, with the header), and CI runs never write. Tests with the same
output share one stored file. When a fix changes the output of only some of the
tests that share a file, those tests get their own file and the others keep
theirs, so a partial run never changes what other tests compare against; the
report prints the command that updates the others too. When a CI lane produces
different output than your environment, its failure ends with a
python scripts/fix_expected.py --apply '...' command (or use
--from-ci <run id>) that writes that lane's output as an override for that
environment only, so other environments keep the stored file and no other
interpreter or formatter is needed locally.
python scripts/fix_expected.py --where '<test>' shows where a test's expected
output is stored.
Direct assert statements are blocked in guarded test modules by
tests/test_assert_helper_usage.py. New test files are guarded by default; add
legacy or unit-focused files to
assert_helper_direct_assert_exempt_files in pyproject.toml only when they
are intentionally outside the generated-output helper policy. Use
@pytest.mark.allow_direct_assert for narrow in-file exceptions that cannot
reasonably be expressed with the shared helpers, such as external-request mock
checks or intermediate-state checks.
๐ฏ Client and server target tests¶
Tests for the generated client and server (FastAPI) packages live in tests/api_generation/. The client runtime
scenarios, one module per behavior such as client_retry_calls.py, live in tests/api_generation/scenarios/, and
test_client_runtime.py drives them. The shared harness, fixture servers, and generation helpers live in
tests/api_generation/support/. The other test_*.py modules (client generation, CLI, typing, and the FastAPI
tests) keep their own cases and use support/ for setup. Both directories are linted and covered like other tests,
except for the modules still listed in [tool.coverage] run.omit in pyproject.toml; issue
#4572 tracks removing those entries.
Tests go through the public generate() function or the datamodel-codegen CLI only. Do not assert on
internal objects, and use mocks only for abnormal paths (injected faults, marked with
@pytest.mark.abnormal_path) and external services.
A generated client copies the codec module of its own model backend only. The dataclass and TypedDict modules,
_runtime/model_codecs/stdlib_dataclasses.py and stdlib_typeddicts.py, are derived from stdlib.py.jinja2 beside
them: edit that source and run python scripts/build_stdlib_codecs.py. Its --check option, which
tests/model_codecs/test_wire_values.py runs, fails when a module differs from what the source derives.
Expected output lives under tests/data/expected/main/generation_platform/:
client/packages/<case>/<backend>/andfastapi/packages/<case>/<backend>/hold generated packages, including serverREADME.mdfiles where the case selects one.client/bindings/<case>/<backend>/<variant>/client/_generated/model_bindings.pypins the model bindings each case generates.client/capabilities/*.txtand the other*.txtreports list copied runtime modules by name. Line counts appear only inminimal. Each capability report also lists the modules the package imports from outside the standard library: those of the printed dependencies, then the others, which only those dependencies' own requirements may provide.client/typing/dependencies/*.txthold the checkers' findings for a package of each backend where only its printed dependencies are installed.- Reports hold listings, diagnostics, and outcomes. Generated source is pinned as files at its generated path, not embedded in a report.
Compare output with the helpers described under "E2E test assertions":
assert_output for reports, assert_generated_modules_output for generate(output=None) modules,
assert_exact_directory_content for trees written to disk, and run_main_and_assert for CLI runs. Regenerate
expected files by running the module with --inline-snapshot=fix (without -n). Under pytest-xdist, expected files
are neither created nor fixed, but comparisons still run.
tests/test_assert_helper_usage.py guards these directories against direct asserts, unit-style checks, and
private imports. Its exceptions are listed in tests/assert_helper_allowlists.py. Those lists only shrink: remove
an entry when you fix its violation, and do not add new ones.
CI certificate stores¶
Keep the lockfile's certifi and uv versions, the pinned Windows uv, the composite action's pip, and the PyPI publishing action updated together when their bundled CAs change. Docker builds update the OS CA package in the shared base stage and use fresh build layers when publishing.
Node used by Wrangler and the release analysis action is selected separately from the GitHub runner's Node executable
that runs JavaScript actions. actions/setup-node updates the former only. Hosted runner updates and third-party
action containers must also include the CA fixes; self-hosted runners need their own updates. The release analysis
action additionally installs Bun and a native CLI, with independent trust stores. As of 2026-10-21, the eMudhra fix for
Bun is merged but awaiting a release. A Node upgrade does not fix those
embedded stores. Consult the incident's runtime update list before updating
their pins or treating the entire CI environment as remediated.
Documentation formatting¶
Ruff formats Python code blocks in Markdown, including hand-written explanations.
Documentation builders surround fixture-backed examples and generated output with
<!-- fmt: off --> and <!-- fmt: on --> so the displayed code keeps the exact
quote style and layout produced by the documented command. Keep these markers in
the builders rather than editing generated Markdown directly.
After changing documentation, regenerate the affected pages and then run
python scripts/build_llms_txt.py. Run tox run -e fix and
tox run -e docs-check-all -- --check to verify formatting and generated content
agree.
Release notes¶
Release notes should describe changes that affect users: features, fixes, generated output, performance, compatibility, installation requirements, and the official GitHub Action or Playground.
Use the skip-changelog PR label for changes that only maintain tests, CI,
release automation, generated documentation, or development tooling. The
release workflow adds it automatically when every changed file is a known
maintenance file, including CHANGELOG updates and release benchmark data.
Other changes need a maintainer's judgment before applying the label.
Check the complete diff before excluding dependency updates, refactors, or
documentation changes. They can affect users through action.yml, packaged
CLI prompt data, Playground assets, or generated code. A PR that mixes
maintenance with user-facing changes should stay in the release notes.
The label only filters GitHub's generated change list. Breaking-change analysis still runs independently, and the full comparison link remains available. Excluded PRs receive no release notification unless they are referenced elsewhere in the release body.
Architecture boundaries¶
Before adding a dependency between layers, check docs/architecture.md and run:
The guard rejects concrete output-backend imports and backend-name inspection in
parsers, concrete backend imports or sys.modules lookups from shared model
modules, private parser._* imports from shared code, parser/backend dependencies
from configuration, and local output-family mappings in input_model.py. Move
output behavior to a neutral capability on DataModel or DataModelFieldBase,
keep backend lifecycle and cache management with its backend or the model
composition root, and move reusable helpers to a neutral package module. Do not
add an allowlist entry for new code. Existing entries document compatibility debt,
have bounded occurrence counts, and fail when stale.
Tests for the checker use source fixtures and expected reports under
tests/data/architecture_boundaries/ and
tests/data/expected/architecture_boundaries/. Keep diagnostics specific enough
that a contributor or coding agent can identify the owning layer and the expected
remediation without reading the checker implementation.
Decision homes¶
A decision that several parser passes or output models need has one home in
docs/architecture.md ("One home per decision"): a pure core and its adapters.
Build the facts in an adapter and call the core; for a decision without a home,
add the home in the same change instead of writing a second copy. A new legacy-*
row needs a row of the "Legacy rows" table in docs/architecture.md with what
deletes it (fix); tests/test_legacy_rows.py fails for a row on one side only.
Parser generation state¶
Parser code must keep generated output, naming order, parse order, and
canonical model selection compatible with existing releases. ModelResolver
remains the only authority for names. Do not move name generation or parser
dispatch into the generation index.
Use the store's GraphBuilder (generation_store.builder(), self._graph_builder in
parser code) for parser-side mutations that affect generation facts; the same
methods on GenerationStore are deprecated:
- Register parsed models with
builder.register_model(model). - Replace references with
builder.replace_data_type_ref(...)orbuilder.detach_data_type_ref(...). - Replace field or nested data types with
replace_field_type(...),replace_nested_data_type(...), orset_nested_data_types(...). - Replace fields or base classes with
set_fields(...),append_field(...),insert_field(...), orset_base_classes(...). - Rename generated models with
rename_model(...); move them withupdate_model_reference(...). - Report models whose fields, base classes, or data types changed without the
store, for example through a model method, with
note_changed_models(...). A refresh records again only the models the store knows have changed.
Parser code should not directly assign data_type.reference, model.fields,
model.base_classes, model.reference.name, or call reference child mutation
helpers. Read dependency information through GenerationIndex queries instead
of using Reference.children as a reverse index.
The local pre-commit hook generation-store-usage runs
scripts/check_generation_store_usage.py, which checks parser/, the
parser-facing hooks in model/ and types.py, and caches kept in the
__dict__ of data types, fields and models, against the attribute-to-fact
table in scripts/fact_tables.py. The hook runs when a
parser file changes; after editing model/, types.py or the table, run the
script yourself (the test suite runs it too).
Writes that bypassed the store when the checker learned them are listed in
scripts/generation_store_allowlist.toml, by path, function and target with a
count. The list only shrinks:
- A new write outside a store operation fails. Route it through a store operation instead of adding an entry.
- When you route a write, the entry no longer matches and the check fails
until you remove it or lower its count.
python scripts/check_generation_store_usage.py --write-allowlistdoes that for you; it refuses to add entries or raise counts. - A refactor that moves a write to another function renames its entry by hand (path, function or target) without raising the total count.
- A new attribute on
DataType, a field or a model needs a row inATTRIBUTE_FACTS; a model method that parser code calls and that reaches a fact write needs an entry inPARSER_HOOK_METHODSof the checker, or inCONSTRUCTION_METHODSorRENDER_ENTRY_POINTSwith a reason.
The runtime write guard (the STORE_WRITES debug check below) sees the same
writes while the generator runs, also where the scan cannot follow them. Every
test runs it, and a test fails when its generations write outside a store
operation at a place the allowlist does not list. A write that only the guard
sees is listed with a witness instead of a count: target = "arguments", the
datamodel-codegen arguments of one run that makes the write.
python scripts/check_generation_store_usage.py --witnesses runs every witness
and reports the entries whose witness no longer makes their write; remove those.
Witness entries are never added for new writes either. When the scan learns to
find a write that a witness covers, --write-allowlist turns the witness into a
count entry; that move is the only one that may raise the counted total.
If a new mutation helper is needed, add the method to SITE_EFFECTS or
NO_SITE_EFFECT (which make up GENERATION_STORE_MUTATION_METHODS), name it in
the store_ops of the rows it writes, and update the checker tests so
contributors get an actionable message.
Debug checks¶
Some internal invariants are checked only when a debug flag turns the check on, so that production runs do not pay
for them. Each area of checks has one environment variable, DATAMODEL_CODE_GENERATOR_CHECK_<AREA>:
| Value | Effect |
|---|---|
unset, empty or 0 |
off, as in production |
1 |
raise at the first violation, with a message that starts with the variable |
report:<path> |
append every violation to <path> as one JSON line (area, message and the area's details) and go on |
Any other value fails the run. A parse reads every variable once, at its start, and its code reports a violation through the mode of the area.
The areas (Area in tests/checks/areas.py) are checks of the test suite and ship nothing in the package: they wrap the internals that tests/checks/seams.py lists, read the variables when the pipeline of a parse
starts and do nothing outside a parse. tests/checks/plugin.py installs them in the test process and, through the
sitecustomize of tests/checks/child that it puts on the PYTHONPATH, in every Python a test starts that imports the
same package. python -m tests.checks <datamodel-codegen args> runs the command line with them outside pytest.
tests/conftest.py sets every area to 1 for the test suite with os.environ.setdefault, so a value you set first
wins: 0 runs the tests without that area and report:<path> collects the violations of a whole run. Under
--codspeed (the CodSpeed workflow and tox -e perf) it sets nothing, so benchmarks measure what production runs;
measure performance outside pytest with the variables unset as well.
FACT_FINALITY raises only for violations that KNOWN_WRITES in tests/checks/finality.py does not list; the listed ones are today's, each with the roadmap item that removes it and a witness, the
datamodel-codegen arguments of one run that makes it. python scripts/check_fact_finality.py --witnesses (and the
test suite) run the witnesses and fail for an entry whose witness no longer makes its violation: remove the entry. The
list only shrinks. A new pass is declared in scripts/pass_decls.py and gets an entry, by name, in the pipeline
tables of Parser; the check runs it inside the scope of that declaration (tests/checks/parse.py:run_pass), the same
script checks the declarations, scripts/check_pass_pipeline.py checks the tables and their order, and a test that fails with "... undeclared: ..."
made a write its pass does not declare.
STORE_WRITES is the exception: today's code still writes outside store operations at the places
scripts/generation_store_allowlist.toml lists, so 1 would stop every generation. Unless you set the variable,
tests/conftest.py (StoreWritesCheck) sets it to report:<file> during each test, with one file per test process,
and fails the test for a write of the package that the allowlist does not list.
| Area | Checks |
|---|---|
SCHEMA_LOCATIONS |
every model parsed from a JSON document has a schema location at the end of L1 (tests/checks/schema_locations.py, Metadata Output) |
MODULE_IMPORTS |
the imports of every module, and those all modules share, equal the replay of their import ledger when the module's import text is written, and each module writes the model names and binds the imports of its import plan (tests/checks/module_imports.py, Model Processing Passes) |
MODEL_GRAPH |
collapsing root models stops before an inlining that would make a data type contain itself, the generation store never meets two Reference objects that share a model key, a copy and its original, every model parsed from a JSON document has an owner or is a root at the end of L1, and the models the output writes are the store's emitted models (tests/checks/model_graph.py, Model Processing Passes, L5 collapse_root_models; Generation Store) |
SCHEMA_GRAPH |
no reader writes a schema object the schema graph validated once and shares (tests/checks/schema_graph.py, Layers, L0) |
STORE_WRITES |
after L1, data types, fields and models change their facts only inside store operations (Generation Store) |
RENDER_MEMOS |
every value a per-parse memo returns equals the value computed anew where it is read (tests/checks/memos.py, Generation Store) |
FACT_FINALITY |
each pass changes only the facts its declaration in scripts/pass_decls.py lists, on the models its scope allows, before they are final (Fact Finality) |
FACT_FINALITY_SWEEP |
with FACT_FINALITY on, a global pass that transforms or repairs compares every model, not only those it touched; it reports nothing itself, so 1 and report:<path> both turn it on |
PASS_TRIGGERS |
a pass whose trigger is false, which its PassSpec skips, runs anyway and changes no fact, memo revision, import, parser attribute, run-state field and warns nothing (tests/checks/finality.py:ParseChecks.idle_pass, Pass Declarations) |
A new area adds a member to Area in tests/checks/areas.py and its seams to tests/checks/seams.py, its variable
to CHECK_AREA_ENVS in tests/conftest.py and a row to this table, and keeps tests that run it on and off.
test_check_seams fails when a seam is not wrapped or a module of the package holds a wrapped function the table does
not list. That every ConfigDict key the generator can write has a row in _requirements.py depends only on the
installed Pydantic, so it is a test (tests/main/test_pydantic_floor.py), not an area. FACT_FINALITY_SWEEP and PASS_TRIGGERS are the exceptions: tests/conftest.py
sets them to 1 only on SWEEP_PYTHON (the newest Python of CI), so one lane runs the slower full comparison and the
passes whose trigger is false, while the others skip those passes as users do.
DATAMODEL_CODE_GENERATOR_CHECK_BUILTIN_FORMATTER_PARITY=1 shares the prefix but is not an area: the test helpers in
tests/main/_builtin_parity.py read it on every call and then generate each output of the default formatters a
second time with the built-in formatter. The CI black-latest lane sets it; the test suite does not.
Comparing output with a release¶
Before sending a change to parsing, naming or rendering, compare the output of your checkout with the latest release over the test inputs and the option sets your change touches:
.venv/bin/python scripts/compare_release.py --base 0.85.0 \
--types pydantic_v2.BaseModel,msgspec.Struct \
--options 'default;--collapse-root-models --reuse-model' \
--extra '--input-file-type openapi' --import-check --show-diff 30 \
tests/data/openapi/*.yaml
The script installs the release once into a cached virtual environment with uv, next to the same versions
of every other package in your environment, and caches the release's outputs, so later runs only generate
your side. A cached output is reused while the input's directory, the local files it references through $ref, the
files named by arguments and the arguments stay the same; pass --no-cache to regenerate it. Old environments and outputs are not removed;
delete the cache directory (~/.cache/datamodel-code-generator/compare-release by default) to reclaim space.
Every run goes through warm worker processes that fork a fresh child per generation in a temporary working
directory, so give files in --options and --extra as absolute paths.
The script prints one line for each run that differs, fails on either side, or imports on one side only,
then a summary. It exits with 1 when any run differs, fails only on your side or imports only on the release
side, or when every run fails on both sides. Use --base-python to compare with another interpreter, such as a build of another branch, and
--no-cache when that build changes without a version change. The script needs a POSIX system.
Calibrating the option recommender¶
scripts/recommender_calibrate.py checks the option recommender's rules against the public schemas listed in
scripts/recommender_corpus.toml. Each entry pins a spec to a commit and a sha256, names its license and the schema
style it stands for, and is either a calibration or a holdout schema. Specs are downloaded into a cache at run time,
used only when the digest matches, and never committed.
.venv/bin/python scripts/recommender_calibrate.py --check-manifest
.venv/bin/python scripts/recommender_calibrate.py --out calibration-run --import-check --jobs 4
For each schema, a run asks the recommender for its recommendation and scores every configuration of the manifest's
decision grid. It writes report.md (regret against the best grid configuration, rules whose decision disagrees with
the grid, threshold and preset proposals, changes since scripts/recommender_calibration.json), scores.json, a
proposed calibration.json and, when a threshold change would agree better with the grid, rules.diff. The script
never edits the rules, the calibration file or the manifest: proposals go through a normal pull request. A spec whose
$ref leaves the document is rejected unless its entry sets stub_external_refs, so generation never reads other
files or the network. Generated code runs only with --import-check, in a temporary directory through an isolated
interpreter.
Runs use only the refs pinned in the manifest and never ask the network whether they are current. Add the opt-in
--check-upstream to an --out run to also ask each schema's source repository for its HEAD with git ls-remote
(this uses the network, but downloads no spec). report.md then gets a "Proposed rotation" section that lists the
schemas whose upstream head differs from the newest pinned ref. Rotating an entry pins that head, with its date and
sha256, as the newest version, and the version pinned before becomes the baseline. Like every proposal, a rotation
is made by hand in a pull request.
The grid scores only the decisions it lists ([grid] in the manifest, keyed by rule decision), and a rule is judged
by its own when: the order of rules within a decision is not modelled.
Writing a recommender rule¶
Rules are data in src/datamodel_code_generator/resources/recommender_rules.json. The schema is measured in
src/datamodel_code_generator/_recommend/_signals.py, rules are loaded, validated and evaluated in _rules.py, and
the report is written by _report.py. This rule from the file (evidence shortened) turns inline enums into
Literal types:
{
"id": "enums.inline-value-sets-as-literal",
"decision": "enum-shape",
"when": {
"all": [
{"signal": "inline_enums", "op": ">=", "value": 5},
{"ratio": ["inline_enums", "all_enums"], "op": ">=", "value": 0.5}
]
},
"set": {"enum_field_as_literal": "all"},
"reason": "Most enums are inline value sets of one property (inline enums: {inline_enums} of {all_enums}). As Enum classes they add one class name each, the longest and most numbered names; Literal types keep the values without a class.",
"evidence": ["GitHub REST API description: 1872 -> 1538 types, names over 40 characters 370 -> 227"]
}
Rule fields¶
| Field | Required | Meaning |
|---|---|---|
id |
Yes | <group>.<name> in lowercase letters, digits and hyphens. The report prints it after each option. |
decision |
Yes | The question the rule answers, such as title-source. Lowercase letters, digits and hyphens. |
applies_to |
No | Limits the rule to input_file_types and output_model_types; empty or missing means all. |
when |
Yes | The condition that fires the rule (see below). |
set |
No | Config fields (snake_case) and their values, spelled as in pyproject.toml. |
value_from |
No | A field computed by pascal_case_input_stem: a class name made from the input file name. |
reason |
Yes | Why the option helps this schema. {name} placeholders are filled from the schema's signals. |
caveats |
No | Texts printed as caveat: after the reason, with placeholders filled. |
evidence |
Yes | Measurements printed as measured:, as written (no placeholders). |
optional |
No | validation or policy: the rule's values are only listed in that section of the report. |
How rules are decided:
- Rules are evaluated in file order. Within one
decision, the first rule that applies to the input and output model type, whosewhenmatches, and that has its values wins; later rules of that decision are skipped. Avalue_fromrule has no value when the input has no file name or a named schema already has that class name, so the next rule of its decision can fire. Put the specific rule of a decision before the general one. - A field belongs to one decision. When candidates are compared, the trial generates the recommendation without
each decision (candidate
recommended - <decision>), so the options a decision sets are kept or left out together. - A fired rule's values join the command when they differ from what the starting preset gives and the user did
not set the field. Values the preset already gives are listed under "Start from"; a rule that only sets fields to
falseand that the preset already follows is listed under "Not recommended". When the user set a field to another value, the user's value is kept and the difference is listed under "Your configuration". - An
optionalrule is listed under "Optional (validation; not in the command)" or "Optional (policy; not in the command)". Its values never join the command, the configuration, or the compared candidates. - A value no CLI option can express (
falsefor a field without a--no-option) makes the recommender start from the next family infamiliesand add a note that says which value.
Conditions¶
A condition is one of these objects, nested freely:
{"signal": "<name>", "op": "<op>", "value": <number>}compares one signal with a number.{"ratio": ["<numerator>", "<denominator>"], "op": "<op>", "value": <number>}compares the ratio of two signals with a number. A zero denominator never matches.{"all": [<condition>, ...]},{"any": [<condition>, ...]}and{"not": <condition>}.
op is one of >=, >, <=, <, == and !=. Signals are integer counts; a yes/no signal such as
root_is_object is 0 or 1. The signal names are the SignalName literal in _signals.py, counted by
measure_signals(). To see a schema's signals, run:
The payload's signals are the counts conditions read; signals and facts (example texts such as
inline_title_examples, or none when empty) are the placeholders reason and caveats may use. Write a literal
brace as {{ or }}. A new signal goes into SignalName and is counted in measure_signals(); it then appears in
every JSON report, so record the changed expected reports.
Validation¶
- Unknown keys, an unknown signal name, a bad
idordecision, or an unknownopfail when the rules load, so every recommender run and test fails. setvalues are validated through the same model as preset values, so a rule cannot name a field that does not exist or give it an invalid value. Anoptionalrule's values are validated as generation options instead, so any generation option may be one.test_recommender_rules_match_the_option_registryintests/main/test_main_recommend_options.pyfails when a field a rule sets has no registry entry listing the rule or has no CLI option, when a field is decided by more than one decision, or when a text uses a placeholder that is neither a signal nor a fact.
What a new rule needs¶
- Measured evidence on public schemas. Run the calibration (add the
decision's values to
[grid]inscripts/recommender_corpus.tomlto score it), or compare the trial of--recommend-options fullwith and without the rule's options on the corpus schemas. Write each measurement inevidence: the schema, what changed, and the numbers. Users read every rule text in the report, so never cite issue or pull request numbers or list known bugs in it. - A test fixture: a small schema under
tests/data/recommend_options/that fires the rule, added as apytest.paramtotest_recommend_options_for_schemas. Run the test to record its expected report undertests/data/expected/main/main_recommend_options/, accept the changed reports of other fixtures with--inline-snapshot=fix, and check that each change is one the rule should make. - A registry entry: list the rule id in
options.<field>.rulesfor every field it sets (see Step 4). - A new
rules_revision: the date of the change.
โ Adding a New CLI Option¶
When adding a new CLI option to datamodel-code-generator, follow these steps:
Step 1: Implement the option (Required)¶
Add the option to src/datamodel_code_generator/arguments.py:
arg_parser.add_argument(
"--my-new-option",
help="Description of what this option does",
action="store_true", # or other action type
)
Step 2: Add a test with documentation marker (Required)¶
Create a test that demonstrates the option and add the @pytest.mark.cli_doc() marker:
@pytest.mark.cli_doc(
options=["--my-new-option"],
input_schema="jsonschema/example.json", # Path relative to tests/data/
cli_args=["--my-new-option"],
golden_output="jsonschema/example_with_my_option.py", # Expected output
)
def test_my_new_option(output_file: Path) -> None:
"""Short description of what the option does.
This docstring becomes the documentation for the option.
Explain when and why users would use this option.
"""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "example.json",
output_path=output_file,
extra_args=["--my-new-option"],
...
)
Step 3: Categorize the option (Optional)¶
By default, new options appear in "General Options". To place in a specific category, add to src/datamodel_code_generator/cli_options.py:
CLI_OPTION_META: dict[str, CLIOptionMeta] = {
# existing rows
**_opts(_C.MODEL, "--my-new-option"), # or _C.FIELD, _C.TYPING, _C.TEMPLATE, etc.
}
_opts(category, *names, group=..., topic=..., **fields) builds the rows of every option in names (space-separated
names share the category, group and fields), so a new option can also be appended to the row of its category and group.
fields sets the other CLIOptionMeta attributes, for example
requires=_relations("--other-option", value=True, message="...").
Step 4: Register the option in the recommender (Required)¶
Every option needs a decision in the option recommender. Add an entry for the config field
(the snake_case name, such as my_new_option for --my-new-option) to the options table of
src/datamodel_code_generator/resources/recommender_rules.json, in alphabetical order.
If a recommender rule decides the option, list the rule ids. Each rule must supply the field through set or
value_from (see Writing a recommender rule):
Otherwise, record why the recommender leaves the option to the user, with one of the categories io, target,
preset, style, runtime-behaviour, formatting, compat, debug, platform, command, or pending:
"my_new_option": {"not_recommended": "style", "reason": "A naming convention only the project can choose."}
Use pending only while a rule for the option is being measured: give the follow-up id in follow_up and the
release it must be decided by in release, such as "release": "0.86.0". Once CHANGELOG.md lists that release,
the registry test fails until the entry gets a rule or a category.
test_every_option_is_registered_with_the_recommender in tests/main/test_main_recommend_options.py fails when a
config field or CLI option has no entry (Options missing from the recommender registry: ...) or an entry has no
option. test_recommender_rules_match_the_option_registry fails when an entry names a rule that does not set that
field, when a rule sets a field whose entry does not list it or that has no CLI option, or when a pending entry
lacks follow_up and release or outlives its release (Rule data problems: ...). Both failures show the entry
format above.
Step 5: Generate and verify documentation¶
# Regenerate CLI docs
$ pytest --collect-cli-docs -p no:xdist -q
$ python scripts/build_cli_docs.py
# Verify generated docs are correct
$ tox run -e docs-check-all -- --check
# If you modified config.py, regenerate config TypedDicts
$ tox run -e config-types
๐ง Troubleshooting¶
If the tox run -e docs-check-all -- --check CLI docs step fails:
- "No test found documenting option --xxx": Add
@pytest.mark.cli_doc(options=["--xxx"], ...)to a test - "File not found: ...": Check that
input_schemaandgolden_outputpaths are correct - "CLI docs are OUT OF DATE": Run
python scripts/build_cli_docs.pyto regenerate
๐ CLI Documentation Marker Reference¶
The cli_doc marker supports:
| Parameter | Required | Description |
|---|---|---|
options |
Yes | List of CLI options this test documents |
input_schema |
Yes | Input schema path (relative to tests/data/) |
cli_args |
Yes | CLI arguments used in the test |
golden_output |
Yes* | Expected output file path |
model_outputs |
No | Dict of model type โ output file (for multi-model tabs) |
version_outputs |
No | Dict of Python version โ output file |
comparison_output |
No | Baseline output without option (for before/after) |
primary |
No | Set True if this is the main example for the option |
*Either golden_output or model_outputs is required.
See existing tests in tests/main/ for examples.