Skip to content

Commit 7d41fef

Browse files
Support JSON files for mapping options (#3071)
* Support JSON files for mapping options * docs: update llms.txt files Generated by GitHub Actions * Address PR review feedback * docs: update llms.txt files Generated by GitHub Actions * docs: update CLI reference documentation and prompt data 🤖 Generated by GitHub Actions * docs: update llms.txt files Generated by GitHub Actions * Add mapping coverage test * Add enum map object validation test * Extract CLI error test helper * Rename CLI error test helper * Add helper docstring --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 5a8cd0d commit 7d41fef

File tree

5 files changed

+199
-5
lines changed

5 files changed

+199
-5
lines changed

docs/cli-reference/model-customization.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,8 @@ You can specify either a single base class as a string, or multiple base classes
927927
- Single: `{"Person": "custom.bases.PersonBase"}`
928928
- Multiple: `{"User": ["mixins.AuditMixin", "mixins.TimestampMixin"]}`
929929

930+
You can pass the mapping either inline as JSON or as a path to a JSON file.
931+
930932
When using multiple base classes, the specified classes are used directly without
931933
adding `BaseModel`. Ensure your mixins inherit from `BaseModel` if needed.
932934

docs/cli-reference/typing-customization.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,8 @@ Override enum/literal generation per-field via JSON mapping.
15351535
The `--enum-field-as-literal-map` option allows per-field control over whether
15361536
to generate Literal types or Enum classes. Overrides `--enum-field-as-literal`.
15371537

1538+
You can pass the mapping either inline as JSON or as a path to a JSON file.
1539+
15381540
!!! tip "Usage"
15391541

15401542
```bash

docs/llms-full.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2156,6 +2156,8 @@ You can specify either a single base class as a string, or multiple base classes
21562156
- Single: `{"Person": "custom.bases.PersonBase"}`
21572157
- Multiple: `{"User": ["mixins.AuditMixin", "mixins.TimestampMixin"]}`
21582158

2159+
You can pass the mapping either inline as JSON or as a path to a JSON file.
2160+
21592161
When using multiple base classes, the specified classes are used directly without
21602162
adding `BaseModel`. Ensure your mixins inherit from `BaseModel` if needed.
21612163

@@ -12717,6 +12719,8 @@ Override enum/literal generation per-field via JSON mapping.
1271712719
The `--enum-field-as-literal-map` option allows per-field control over whether
1271812720
to generate Literal types or Enum classes. Overrides `--enum-field-as-literal`.
1271912721

12722+
You can pass the mapping either inline as JSON or as a path to a JSON file.
12723+
1272012724
!!! tip "Usage"
1272112725

1272212726
```bash

src/datamodel_code_generator/arguments.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,28 @@ def _external_ref_mapping(value: str) -> str:
8585
return value
8686

8787

88+
def _json_value_or_file(value: str) -> dict[str, object]:
89+
"""Parse a JSON value or load it from a JSON file path."""
90+
path = Path(value).expanduser()
91+
if path.is_file():
92+
try:
93+
json_input = path.read_text(encoding=DEFAULT_ENCODING)
94+
except (OSError, UnicodeDecodeError) as e:
95+
msg = f"Unable to read JSON file {value!r}: {e}"
96+
raise ArgumentTypeError(msg) from e
97+
else:
98+
json_input = value
99+
try:
100+
result = json.loads(json_input)
101+
except json.JSONDecodeError as e:
102+
msg = f"Invalid JSON: {e}"
103+
raise ArgumentTypeError(msg) from e
104+
if not isinstance(result, dict):
105+
msg = f"Expected a JSON object, got {type(result).__name__}"
106+
raise ArgumentTypeError(msg)
107+
return result
108+
109+
88110
class SortingHelpFormatter(RawDescriptionHelpFormatter):
89111
"""Help formatter that sorts arguments, adds color to section headers, and preserves epilog formatting."""
90112

@@ -505,10 +527,10 @@ def start_section(self, heading: str | None) -> None:
505527
)
506528
typing_options.add_argument(
507529
"--base-class-map",
508-
help="Model-specific base class mapping (JSON). "
530+
help="Model-specific base class mapping (JSON or JSON file path). "
509531
'Example: \'{"MyModel": "custom.BaseA", "OtherModel": "custom.BaseB"}\'. '
510532
"Priority: base-class-map > customBasePath (in schema) > base-class.",
511-
type=json.loads,
533+
type=_json_value_or_file,
512534
default=None,
513535
)
514536
typing_options.add_argument(
@@ -522,11 +544,11 @@ def start_section(self, heading: str | None) -> None:
522544
)
523545
typing_options.add_argument(
524546
"--enum-field-as-literal-map",
525-
help="Per-field override for enum/literal generation. "
547+
help="Per-field override for enum/literal generation (JSON or JSON file path). "
526548
"Format: JSON object mapping field names to 'literal' or 'enum'. "
527549
'Example: \'{"status": "literal", "priority": "enum"}\'. '
528550
"Overrides --enum-field-as-literal for matched fields.",
529-
type=json.loads,
551+
type=_json_value_or_file,
530552
default=None,
531553
)
532554
typing_options.add_argument(

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@
5252
FixtureRequest = pytest.FixtureRequest
5353

5454

55+
def assert_run_main_with_args_error(args: list[str], capsys: pytest.CaptureFixture[str], expected_error: str) -> None:
56+
"""Assert that running the CLI exits with code 2 and emits the expected error."""
57+
with pytest.raises(SystemExit) as exc_info:
58+
run_main_with_args(args)
59+
assert exc_info.value.code == 2
60+
captured = capsys.readouterr()
61+
assert expected_error in captured.err
62+
63+
5564
def _install_test_my_app(base_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
5665
package_dir = base_dir / "my_app"
5766
package_dir.mkdir()
@@ -2981,6 +2990,8 @@ def test_main_jsonschema_custom_base_path(output_file: Path) -> None:
29812990
- Single: `{"Person": "custom.bases.PersonBase"}`
29822991
- Multiple: `{"User": ["mixins.AuditMixin", "mixins.TimestampMixin"]}`
29832992
2993+
You can pass the mapping either inline as JSON or as a path to a JSON file.
2994+
29842995
When using multiple base classes, the specified classes are used directly without
29852996
adding `BaseModel`. Ensure your mixins inherit from `BaseModel` if needed.""",
29862997
input_schema="jsonschema/base_class_map.json",
@@ -3010,6 +3021,87 @@ def test_main_jsonschema_base_class_map(output_file: Path) -> None:
30103021
)
30113022

30123023

3024+
def test_main_jsonschema_base_class_map_from_file(output_file: Path, tmp_path: Path) -> None:
3025+
"""Test base_class_map loaded from a JSON file."""
3026+
mapping_path = tmp_path / "base_class_map.json"
3027+
mapping_path.write_text(
3028+
json.dumps({"Person": "custom.bases.PersonBase", "Animal": "custom.bases.AnimalBase"}),
3029+
encoding="utf-8",
3030+
)
3031+
run_main_and_assert(
3032+
input_path=JSON_SCHEMA_DATA_PATH / "base_class_map.json",
3033+
output_path=output_file,
3034+
input_file_type="jsonschema",
3035+
assert_func=assert_file_content,
3036+
expected_file="base_class_map.py",
3037+
extra_args=["--base-class-map", str(mapping_path)],
3038+
)
3039+
3040+
3041+
def test_main_jsonschema_base_class_map_from_file_invalid_json(
3042+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
3043+
) -> None:
3044+
"""Test invalid JSON file passed to --base-class-map."""
3045+
mapping_path = tmp_path / "base_class_map.json"
3046+
mapping_path.write_text("{invalid json}", encoding="utf-8")
3047+
assert_run_main_with_args_error(
3048+
[
3049+
"--input",
3050+
str(JSON_SCHEMA_DATA_PATH / "base_class_map.json"),
3051+
"--output",
3052+
str(tmp_path / "output.py"),
3053+
"--input-file-type",
3054+
"jsonschema",
3055+
"--base-class-map",
3056+
str(mapping_path),
3057+
],
3058+
capsys,
3059+
"Invalid JSON:",
3060+
)
3061+
3062+
3063+
def test_main_jsonschema_base_class_map_from_file_invalid_encoding(
3064+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
3065+
) -> None:
3066+
"""Test invalid-encoding JSON file passed to --base-class-map."""
3067+
mapping_path = tmp_path / "base_class_map.json"
3068+
mapping_path.write_bytes(b"\x80")
3069+
assert_run_main_with_args_error(
3070+
[
3071+
"--input",
3072+
str(JSON_SCHEMA_DATA_PATH / "base_class_map.json"),
3073+
"--output",
3074+
str(tmp_path / "output.py"),
3075+
"--input-file-type",
3076+
"jsonschema",
3077+
"--base-class-map",
3078+
str(mapping_path),
3079+
],
3080+
capsys,
3081+
"Unable to read JSON file",
3082+
)
3083+
3084+
3085+
def test_main_jsonschema_base_class_map_inline_requires_json_object(
3086+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
3087+
) -> None:
3088+
"""Test non-object JSON passed to --base-class-map."""
3089+
assert_run_main_with_args_error(
3090+
[
3091+
"--input",
3092+
str(JSON_SCHEMA_DATA_PATH / "base_class_map.json"),
3093+
"--output",
3094+
str(tmp_path / "output.py"),
3095+
"--input-file-type",
3096+
"jsonschema",
3097+
"--base-class-map",
3098+
'["custom.bases.PersonBase"]',
3099+
],
3100+
capsys,
3101+
"Expected a JSON object",
3102+
)
3103+
3104+
30133105
def test_main_jsonschema_custom_base_paths_list(output_file: Path) -> None:
30143106
"""Test customBasePath with list of base classes."""
30153107
run_main_and_assert(
@@ -4527,7 +4619,9 @@ def test_main_typed_dict_no_closed(output_file: Path) -> None:
45274619
option_description="""Override enum/literal generation per-field via JSON mapping.
45284620
45294621
The `--enum-field-as-literal-map` option allows per-field control over whether
4530-
to generate Literal types or Enum classes. Overrides `--enum-field-as-literal`.""",
4622+
to generate Literal types or Enum classes. Overrides `--enum-field-as-literal`.
4623+
4624+
You can pass the mapping either inline as JSON or as a path to a JSON file.""",
45314625
input_schema="jsonschema/enum_field_as_literal_map.json",
45324626
cli_args=["--enum-field-as-literal-map", '{"status": "literal"}'],
45334627
golden_output="jsonschema/enum_field_as_literal_map.py",
@@ -4551,6 +4645,20 @@ def test_main_enum_field_as_literal_map(output_file: Path) -> None:
45514645
)
45524646

45534647

4648+
def test_main_enum_field_as_literal_map_from_file(output_file: Path, tmp_path: Path) -> None:
4649+
"""Test enum_field_as_literal_map loaded from a JSON file."""
4650+
mapping_path = tmp_path / "enum_field_as_literal_map.json"
4651+
mapping_path.write_text(json.dumps({"status": "literal"}), encoding="utf-8")
4652+
run_main_and_assert(
4653+
input_path=JSON_SCHEMA_DATA_PATH / "enum_field_as_literal_map.json",
4654+
output_path=output_file,
4655+
input_file_type=None,
4656+
assert_func=assert_file_content,
4657+
expected_file="enum_field_as_literal_map.py",
4658+
extra_args=["--enum-field-as-literal-map", str(mapping_path)],
4659+
)
4660+
4661+
45544662
def test_main_enum_field_as_literal_map_override_global(output_file: Path) -> None:
45554663
"""Test --enum-field-as-literal-map overrides global --enum-field-as-literal."""
45564664
run_main_and_assert(
@@ -4568,6 +4676,62 @@ def test_main_enum_field_as_literal_map_override_global(output_file: Path) -> No
45684676
)
45694677

45704678

4679+
def test_main_enum_field_as_literal_map_invalid_json_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
4680+
"""Test invalid JSON file passed to --enum-field-as-literal-map."""
4681+
mapping_path = tmp_path / "enum_field_as_literal_map.json"
4682+
mapping_path.write_text("{invalid json}", encoding="utf-8")
4683+
assert_run_main_with_args_error(
4684+
[
4685+
"--input",
4686+
str(JSON_SCHEMA_DATA_PATH / "enum_field_as_literal_map.json"),
4687+
"--output",
4688+
str(tmp_path / "output.py"),
4689+
"--enum-field-as-literal-map",
4690+
str(mapping_path),
4691+
],
4692+
capsys,
4693+
"Invalid JSON:",
4694+
)
4695+
4696+
4697+
def test_main_enum_field_as_literal_map_invalid_encoding_json_file(
4698+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
4699+
) -> None:
4700+
"""Test invalid-encoding JSON file passed to --enum-field-as-literal-map."""
4701+
mapping_path = tmp_path / "enum_field_as_literal_map.json"
4702+
mapping_path.write_bytes(b"\x80")
4703+
assert_run_main_with_args_error(
4704+
[
4705+
"--input",
4706+
str(JSON_SCHEMA_DATA_PATH / "enum_field_as_literal_map.json"),
4707+
"--output",
4708+
str(tmp_path / "output.py"),
4709+
"--enum-field-as-literal-map",
4710+
str(mapping_path),
4711+
],
4712+
capsys,
4713+
"Unable to read JSON file",
4714+
)
4715+
4716+
4717+
def test_main_enum_field_as_literal_map_inline_requires_json_object(
4718+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
4719+
) -> None:
4720+
"""Test non-object JSON passed to --enum-field-as-literal-map."""
4721+
assert_run_main_with_args_error(
4722+
[
4723+
"--input",
4724+
str(JSON_SCHEMA_DATA_PATH / "enum_field_as_literal_map.json"),
4725+
"--output",
4726+
str(tmp_path / "output.py"),
4727+
"--enum-field-as-literal-map",
4728+
'["literal"]',
4729+
],
4730+
capsys,
4731+
"Expected a JSON object",
4732+
)
4733+
4734+
45714735
def test_main_x_enum_field_as_literal(output_file: Path) -> None:
45724736
"""Test x-enum-field-as-literal schema extension for per-field control."""
45734737
run_main_and_assert(

0 commit comments

Comments
 (0)