Skip to content

๐Ÿ› ๏ธ 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>/ and fastapi/packages/<case>/<backend>/ hold generated packages, including server README.md files where the case selects one.
  • client/bindings/<case>/<backend>/<variant>/client/_generated/model_bindings.py pins the model bindings each case generates.
  • client/capabilities/*.txt and the other *.txt reports list copied runtime modules by name. Line counts appear only in minimal. 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/*.txt hold 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:

python scripts/check_architecture_boundaries.py

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(...) or builder.detach_data_type_ref(...).
  • Replace field or nested data types with replace_field_type(...), replace_nested_data_type(...), or set_nested_data_types(...).
  • Replace fields or base classes with set_fields(...), append_field(...), insert_field(...), or set_base_classes(...).
  • Rename generated models with rename_model(...); move them with update_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-allowlist does 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 in ATTRIBUTE_FACTS; a model method that parser code calls and that reaches a fact write needs an entry in PARSER_HOOK_METHODS of the checker, or in CONSTRUCTION_METHODS or RENDER_ENTRY_POINTS with 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, whose when matches, and that has its values wins; later rules of that decision are skipped. A value_from rule 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 false and 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 optional rule 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 (false for a field without a --no- option) makes the recommender start from the next family in families and 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:

datamodel-codegen --input schema.yaml --recommend-options quick --output-format json

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 id or decision, or an unknown op fail when the rules load, so every recommender run and test fails.
  • set values 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. An optional rule's values are validated as generation options instead, so any generation option may be one.
  • test_recommender_rules_match_the_option_registry in tests/main/test_main_recommend_options.py fails 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

  1. Measured evidence on public schemas. Run the calibration (add the decision's values to [grid] in scripts/recommender_corpus.toml to score it), or compare the trial of --recommend-options full with and without the rule's options on the corpus schemas. Write each measurement in evidence: 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.
  2. A test fixture: a small schema under tests/data/recommend_options/ that fires the rule, added as a pytest.param to test_recommend_options_for_schemas. Run the test to record its expected report under tests/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.
  3. A registry entry: list the rule id in options.<field>.rules for every field it sets (see Step 4).
  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):

"my_new_option": {"rules": ["naming.my-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_schema and golden_output paths are correct
  • "CLI docs are OUT OF DATE": Run python scripts/build_cli_docs.py to 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.