From 7ee780c50ddf8cbaf82997c6bce743aedd03e559 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Mon, 5 Jan 2026 16:59:32 +0000 Subject: [PATCH 1/7] Add --jsonschema-version and --openapi-version CLI options --- .../_types/parser_config_dicts.py | 5 ++++- src/datamodel_code_generator/arguments.py | 20 +++++++++++++++++++ src/datamodel_code_generator/config.py | 5 +++++ .../parser/jsonschema.py | 5 ++++- .../parser/openapi.py | 5 ++++- 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index ff4d71dd7..6b8cee410 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -19,8 +19,10 @@ CollapseRootModelsNameStrategy, DataclassArguments, FieldTypeCollisionStrategy, + JsonSchemaVersion, NamingStrategy, OpenAPIScope, + OpenAPIVersion, ReadOnlyWriteOnlyModelType, ReuseScope, StrictTypes, @@ -167,7 +169,7 @@ class GraphQLParserConfigDict(ParserConfigDict, closed=True): class JSONSchemaParserConfigDict(ParserConfigDict): - pass + jsonschema_version: NotRequired[JsonSchemaVersion | None] class OpenAPIParserConfigDict(JSONSchemaParserConfigDict, closed=True): @@ -175,6 +177,7 @@ class OpenAPIParserConfigDict(JSONSchemaParserConfigDict, closed=True): include_path_parameters: NotRequired[bool] use_status_code_in_response_name: NotRequired[bool] openapi_include_paths: NotRequired[list[str] | None] + openapi_version: NotRequired[OpenAPIVersion | None] ModelDict: TypeAlias = ParserConfigDict | GraphQLParserConfigDict | JSONSchemaParserConfigDict | OpenAPIParserConfigDict diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index bdf680fb9..f74ce1989 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -26,9 +26,11 @@ FieldTypeCollisionStrategy, InputFileType, InputModelRefStrategy, + JsonSchemaVersion, ModuleSplitMode, NamingStrategy, OpenAPIScope, + OpenAPIVersion, ReadOnlyWriteOnlyModelType, ReuseScope, StrictTypes, @@ -984,6 +986,24 @@ def start_section(self, heading: str | None) -> None: action="store_true", default=None, ) +openapi_options.add_argument( + "--openapi-version", + help="OpenAPI version to use (default: auto-detect from 'openapi' field). " + "Use 'auto' for auto-detection, '3.0' for OpenAPI 3.0.x, '3.1' for OpenAPI 3.1.x.", + choices=[v.value for v in OpenAPIVersion], + default=None, +) + +# ====================================================================================== +# Options specific to JSON Schema input +# ====================================================================================== +base_options.add_argument( + "--jsonschema-version", + help="JSON Schema version to use (default: auto-detect from '$schema' field). " + "Use 'auto' for auto-detection, or specify a draft version explicitly.", + choices=[v.value for v in JsonSchemaVersion], + default=None, +) # ====================================================================================== # Options specific to GraphQL input schemas diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index ac37fd6dc..b9ebb1855 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -22,9 +22,11 @@ FieldTypeCollisionStrategy, GraphQLScope, InputFileType, + JsonSchemaVersion, ModuleSplitMode, NamingStrategy, OpenAPIScope, + OpenAPIVersion, ReadOnlyWriteOnlyModelType, ReuseScope, TargetPydanticVersion, @@ -350,6 +352,8 @@ class GraphQLParserConfig(ParserConfig): class JSONSchemaParserConfig(ParserConfig): """Configuration model for JsonSchemaParser.__init__().""" + jsonschema_version: JsonSchemaVersion | None = None + class OpenAPIParserConfig(JSONSchemaParserConfig): """Configuration model for OpenAPIParser.__init__().""" @@ -358,6 +362,7 @@ class OpenAPIParserConfig(JSONSchemaParserConfig): include_path_parameters: bool = False use_status_code_in_response_name: bool = False openapi_include_paths: list[str] | None = None + openapi_version: OpenAPIVersion | None = None class ParseConfig(BaseModel): diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index ecedf2185..d02fb2e89 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -773,12 +773,15 @@ def schema_paths(self) -> list[tuple[str, list[str]]]: @cached_property def schema_features(self) -> JsonSchemaFeatures: - """Get schema features based on detected version.""" + """Get schema features based on config or detected version.""" from datamodel_code_generator.parser.schema_version import ( # noqa: PLC0415 JsonSchemaFeatures, detect_jsonschema_version, ) + config_version = getattr(self.config, "jsonschema_version", None) + if config_version is not None and config_version != JsonSchemaVersion.Auto: + return JsonSchemaFeatures.from_version(config_version) version = detect_jsonschema_version(self.raw_obj) if self.raw_obj else JsonSchemaVersion.Auto return JsonSchemaFeatures.from_version(version) diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index 5f6c57c83..99916ac51 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -172,12 +172,15 @@ class OpenAPIParser(JsonSchemaParser): @cached_property def schema_features(self) -> OpenAPISchemaFeatures: - """Get schema features based on detected OpenAPI version.""" + """Get schema features based on config or detected OpenAPI version.""" from datamodel_code_generator.parser.schema_version import ( # noqa: PLC0415 OpenAPISchemaFeatures, detect_openapi_version, ) + config_version = getattr(self.config, "openapi_version", None) + if config_version is not None and config_version != OpenAPIVersion.Auto: + return OpenAPISchemaFeatures.from_openapi_version(config_version) version = detect_openapi_version(self.raw_obj) if self.raw_obj else OpenAPIVersion.Auto return OpenAPISchemaFeatures.from_openapi_version(version) From e0ad9a6c5fc068e9639ecd70a19e3dd81ddabaef Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Mon, 5 Jan 2026 17:01:39 +0000 Subject: [PATCH 2/7] Add --schema-version and --schema-version-mode CLI options --- src/datamodel_code_generator/__init__.py | 41 +++++++++- src/datamodel_code_generator/__main__.py | 5 ++ .../_types/generate_config_dict.py | 3 + src/datamodel_code_generator/arguments.py | 30 ++++---- src/datamodel_code_generator/cli_options.py | 2 + src/datamodel_code_generator/config.py | 3 + .../parser/jsonschema.py | 2 +- .../expected/main/input_model/config_class.py | 5 ++ .../test_public_api_signature_baseline.py | 3 + tests/parser/test_schema_version.py | 74 +++++++++++++++++++ 10 files changed, 149 insertions(+), 19 deletions(-) diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index e63c9840e..23a79bf21 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -65,7 +65,12 @@ from datamodel_code_generator.parser import DefaultPutDict, LiteralType if TYPE_CHECKING: - from datamodel_code_generator._types import GraphQLParserConfigDict, OpenAPIParserConfigDict, ParserConfigDict + from datamodel_code_generator._types import ( + GraphQLParserConfigDict, + JSONSchemaParserConfigDict, + OpenAPIParserConfigDict, + ParserConfigDict, + ) from datamodel_code_generator._types.generate_config_dict import GenerateConfigDict from datamodel_code_generator.config import GenerateConfig, ParserConfig @@ -456,7 +461,10 @@ def _build_module_content( def _create_parser_config( config_class: type[_ConfigT], generate_config: GenerateConfig, - additional_options: ParserConfigDict | OpenAPIParserConfigDict | GraphQLParserConfigDict, + additional_options: ParserConfigDict + | JSONSchemaParserConfigDict + | OpenAPIParserConfigDict + | GraphQLParserConfigDict, ) -> _ConfigT: """Create a parser config from GenerateConfig with additional options. @@ -735,6 +743,28 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: ), } + # Convert schema_version string to appropriate enum based on input type + jsonschema_version: JsonSchemaVersion | None = None + openapi_version: OpenAPIVersion | None = None + if config.schema_version and config.schema_version != "auto": + if input_file_type == InputFileType.OpenAPI: + try: + openapi_version = OpenAPIVersion(config.schema_version) + except ValueError: + valid = [v.value for v in OpenAPIVersion] + msg = f"Invalid OpenAPI version: {config.schema_version}. Valid values: {valid}" + raise Error(msg) from None + elif input_file_type == InputFileType.GraphQL: + msg = f"--schema-version is not supported for {input_file_type.value}" + raise Error(msg) + else: + try: + jsonschema_version = JsonSchemaVersion(config.schema_version) + except ValueError: + valid = [v.value for v in JsonSchemaVersion] + msg = f"Invalid JSON Schema version: {config.schema_version}. Valid values: {valid}" + raise Error(msg) from None + if input_file_type == InputFileType.OpenAPI: from datamodel_code_generator.parser.openapi import OpenAPIParser # noqa: PLC0415 @@ -743,6 +773,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: "include_path_parameters": config.include_path_parameters, "use_status_code_in_response_name": config.use_status_code_in_response_name, "openapi_include_paths": config.openapi_include_paths, + "openapi_version": openapi_version, **additional_options, } parser_config = _create_parser_config(OpenAPIParserConfig, config, openapi_additional_options) @@ -760,7 +791,11 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: else: from datamodel_code_generator.parser.jsonschema import JsonSchemaParser # noqa: PLC0415 - parser_config = _create_parser_config(JSONSchemaParserConfig, config, additional_options) + jsonschema_additional_options: JSONSchemaParserConfigDict = { + "jsonschema_version": jsonschema_version, + **additional_options, + } + parser_config = _create_parser_config(JSONSchemaParserConfig, config, jsonschema_additional_options) parser = JsonSchemaParser(source=source, config=parser_config) # ty: ignore with chdir(config.output): diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 6039cb9c7..e7d4c68ba 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -69,6 +69,7 @@ ReadOnlyWriteOnlyModelType, ReuseScope, TargetPydanticVersion, + VersionMode, enable_debug_message, generate, ) @@ -620,6 +621,8 @@ def validate_class_name_affix_scope(cls, v: str | ClassNameAffixScope | None) -> module_split_mode: Optional[ModuleSplitMode] = None # noqa: UP045 watch: bool = False watch_delay: float = 0.5 + schema_version: Optional[str] = None # noqa: UP045 + schema_version_mode: Optional[VersionMode] = None # noqa: UP045 def merge_args(self, args: Namespace) -> None: """Merge command-line arguments into config.""" @@ -1063,6 +1066,8 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 module_split_mode=config.module_split_mode, validators=validators, default_value_overrides=default_value_overrides, + schema_version=config.schema_version, + schema_version_mode=config.schema_version_mode, ) if output is None and result is not None: # pragma: no cover diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index d997a3344..da3c652e3 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -32,6 +32,7 @@ StrictTypes, TargetPydanticVersion, UnionMode, + VersionMode, ) from datamodel_code_generator.format import DateClassType, DatetimeClassType, Formatter, PythonVersion from datamodel_code_generator.parser import LiteralType @@ -168,6 +169,8 @@ class GenerateConfigDict(TypedDict, closed=True): field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None] module_split_mode: NotRequired[ModuleSplitMode | None] default_value_overrides: NotRequired[Mapping[str, Any] | None] + schema_version: NotRequired[str | None] + schema_version_mode: NotRequired[VersionMode | None] class ValidatorDefinition(TypedDict): diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index f74ce1989..67dcc0ba9 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -26,16 +26,15 @@ FieldTypeCollisionStrategy, InputFileType, InputModelRefStrategy, - JsonSchemaVersion, ModuleSplitMode, NamingStrategy, OpenAPIScope, - OpenAPIVersion, ReadOnlyWriteOnlyModelType, ReuseScope, StrictTypes, TargetPydanticVersion, UnionMode, + VersionMode, ) from datamodel_code_generator.format import DateClassType, DatetimeClassType, Formatter, PythonVersion from datamodel_code_generator.parser import LiteralType @@ -986,22 +985,23 @@ def start_section(self, heading: str | None) -> None: action="store_true", default=None, ) -openapi_options.add_argument( - "--openapi-version", - help="OpenAPI version to use (default: auto-detect from 'openapi' field). " - "Use 'auto' for auto-detection, '3.0' for OpenAPI 3.0.x, '3.1' for OpenAPI 3.1.x.", - choices=[v.value for v in OpenAPIVersion], - default=None, -) - # ====================================================================================== -# Options specific to JSON Schema input +# Schema version options (for both JSON Schema and OpenAPI) # ====================================================================================== base_options.add_argument( - "--jsonschema-version", - help="JSON Schema version to use (default: auto-detect from '$schema' field). " - "Use 'auto' for auto-detection, or specify a draft version explicitly.", - choices=[v.value for v in JsonSchemaVersion], + "--schema-version", + help="Schema version. Valid values depend on input type: " + "JsonSchema: auto, draft-04, draft-06, draft-07, 2019-09, 2020-12. " + "OpenAPI: auto, 3.0, 3.1. " + "(default: auto - detected from $schema or openapi field)", + default=None, +) +base_options.add_argument( + "--schema-version-mode", + help="Schema version validation mode. " + "'lenient': accept all features regardless of version (default). " + "'strict': warn on features outside declared/detected version.", + choices=[m.value for m in VersionMode], default=None, ) diff --git a/src/datamodel_code_generator/cli_options.py b/src/datamodel_code_generator/cli_options.py index 570cf73b0..ef993e0be 100644 --- a/src/datamodel_code_generator/cli_options.py +++ b/src/datamodel_code_generator/cli_options.py @@ -70,6 +70,8 @@ class CLIOptionMeta: "--input-model-ref-strategy": CLIOptionMeta(name="--input-model-ref-strategy", category=OptionCategory.BASE), "--input-file-type": CLIOptionMeta(name="--input-file-type", category=OptionCategory.BASE), "--encoding": CLIOptionMeta(name="--encoding", category=OptionCategory.BASE), + "--schema-version": CLIOptionMeta(name="--schema-version", category=OptionCategory.BASE), + "--schema-version-mode": CLIOptionMeta(name="--schema-version-mode", category=OptionCategory.BASE), # ========================================================================== # Model Customization # ========================================================================== diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index b9ebb1855..501308a21 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -30,6 +30,7 @@ ReadOnlyWriteOnlyModelType, ReuseScope, TargetPydanticVersion, + VersionMode, ) from datamodel_code_generator.format import ( DateClassType, @@ -207,6 +208,8 @@ class Config: field_type_collision_strategy: FieldTypeCollisionStrategy | None = None module_split_mode: ModuleSplitMode | None = None default_value_overrides: Mapping[str, Any] | None = None + schema_version: str | None = None + schema_version_mode: VersionMode | None = None class ParserConfig(BaseModel): diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index d02fb2e89..53a933024 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -664,7 +664,7 @@ class JsonSchemaParser(Parser["JSONSchemaParserConfig"]): }) @classmethod - def _create_default_config(cls, options: JSONSchemaParserConfigDict) -> JSONSchemaParserConfig: + def _create_default_config(cls, options: JSONSchemaParserConfigDict) -> JSONSchemaParserConfig: # type: ignore[override] """Create a JSONSchemaParserConfig from options.""" from datamodel_code_generator import types as types_module # noqa: PLC0415 from datamodel_code_generator.config import JSONSchemaParserConfig # noqa: PLC0415 diff --git a/tests/data/expected/main/input_model/config_class.py b/tests/data/expected/main/input_model/config_class.py index 184b3d79f..285b094a4 100644 --- a/tests/data/expected/main/input_model/config_class.py +++ b/tests/data/expected/main/input_model/config_class.py @@ -114,6 +114,9 @@ class DataclassArguments(TypedDict, closed=True): ValidatorMode: TypeAlias = Literal['before', 'after', 'wrap', 'plain'] +VersionMode: TypeAlias = Literal['lenient', 'strict'] + + class GenerateConfig(TypedDict, closed=True): input_filename: NotRequired[str | None] input_file_type: NotRequired[InputFileType] @@ -246,6 +249,8 @@ class GenerateConfig(TypedDict, closed=True): field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None] module_split_mode: NotRequired[ModuleSplitMode | None] default_value_overrides: NotRequired[Mapping[str, Any] | None] + schema_version: NotRequired[str | None] + schema_version_mode: NotRequired[VersionMode | None] class ValidatorDefinition(TypedDict): diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index 3c0327259..9d2c7efee 100644 --- a/tests/main/test_public_api_signature_baseline.py +++ b/tests/main/test_public_api_signature_baseline.py @@ -28,6 +28,7 @@ ReadOnlyWriteOnlyModelType, ReuseScope, TargetPydanticVersion, + VersionMode, ) from datamodel_code_generator.format import PythonVersionMin from datamodel_code_generator.model import DataModel, DataModelFieldBase @@ -187,6 +188,8 @@ def _baseline_generate( all_exports_collision_strategy: AllExportsCollisionStrategy | None = None, field_type_collision_strategy: FieldTypeCollisionStrategy | None = None, module_split_mode: ModuleSplitMode | None = None, + schema_version: str | None = None, + schema_version_mode: VersionMode | None = None, ) -> str | object | None: raise NotImplementedError diff --git a/tests/parser/test_schema_version.py b/tests/parser/test_schema_version.py index 68f100bb4..f073f00a3 100644 --- a/tests/parser/test_schema_version.py +++ b/tests/parser/test_schema_version.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + import pytest from inline_snapshot import snapshot @@ -14,6 +16,9 @@ detect_openapi_version, ) +# Path to test data +JSON_SCHEMA_DATA_PATH = Path(__file__).parent.parent / "data" / "jsonschema" + def test_detect_jsonschema_version_draft4() -> None: """Test detection of Draft 4 from $schema field.""" @@ -421,3 +426,72 @@ def test_openapi_parser_schema_features_detection() -> None: features = parser.schema_features assert features.nullable_keyword == snapshot(False) assert features.null_in_type_array == snapshot(True) + + +def test_jsonschema_parser_config_version_override() -> None: + """Test that JsonSchemaParser uses config version over auto-detection.""" + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser("", jsonschema_version=JsonSchemaVersion.Draft4) + parser.raw_obj = {"$schema": "http://json-schema.org/draft-07/schema#"} + features = parser.schema_features + assert features.id_field == snapshot("id") + assert features.boolean_schemas == snapshot(False) + + +def test_openapi_parser_config_version_override() -> None: + """Test that OpenAPIParser uses config version over auto-detection.""" + from datamodel_code_generator.parser.openapi import OpenAPIParser + + parser = OpenAPIParser("", openapi_version=OpenAPIVersion.V30) + parser.raw_obj = {"openapi": "3.1.0"} + features = parser.schema_features + assert features.nullable_keyword == snapshot(True) + assert features.null_in_type_array == snapshot(False) + + +@pytest.mark.cli_doc( + options=["--schema-version"], + option_description="""Schema version to use for parsing. + +The `--schema-version` option specifies the schema version to use instead of auto-detection. +Valid values depend on input type: JsonSchema (draft-04, draft-06, draft-07, 2019-09, 2020-12) +or OpenAPI (3.0, 3.1). Default is 'auto' (detected from $schema or openapi field).""", + input_schema="jsonschema/simple_string.json", + cli_args=["--schema-version", "draft-07"], + golden_output="jsonschema/simple_string.py", +) +def test_cli_schema_version_jsonschema() -> None: + """Test --schema-version option with JSON Schema input.""" + from datamodel_code_generator import generate + + result = generate( + JSON_SCHEMA_DATA_PATH / "simple_string.json", + input_file_type=datamodel_code_generator.InputFileType.JsonSchema, + schema_version="draft-07", + ) + assert result is not None + assert "class Model" in result or "Model" in result + + +@pytest.mark.cli_doc( + options=["--schema-version-mode"], + option_description="""Schema version validation mode. + +The `--schema-version-mode` option controls how schema version validation is performed. +'lenient' (default): accept all features regardless of version. +'strict': warn on features outside the declared/detected version.""", + input_schema="jsonschema/simple_string.json", + cli_args=["--schema-version-mode", "lenient"], + golden_output="jsonschema/simple_string.py", +) +def test_cli_schema_version_mode() -> None: + """Test --schema-version-mode option.""" + from datamodel_code_generator import generate + + result = generate( + JSON_SCHEMA_DATA_PATH / "simple_string.json", + input_file_type=datamodel_code_generator.InputFileType.JsonSchema, + schema_version_mode=VersionMode.Lenient, + ) + assert result is not None From a0b9c1005cc65218d58a94b1f85a6ecbf6251e52 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Mon, 5 Jan 2026 18:54:17 +0000 Subject: [PATCH 3/7] Regenerate CLI docs --- docs/cli-reference/base-options.md | 72 +++++++++++++++++++++++++++ docs/cli-reference/index.md | 4 +- docs/cli-reference/quick-reference.md | 4 ++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference/base-options.md b/docs/cli-reference/base-options.md index 12be41743..1f633a906 100644 --- a/docs/cli-reference/base-options.md +++ b/docs/cli-reference/base-options.md @@ -10,6 +10,8 @@ | [`--input-model`](#input-model) | Import a Python type or dict schema from a module. | | [`--input-model-ref-strategy`](#input-model-ref-strategy) | Strategy for referenced types when using --input-model. | | [`--output`](#output) | Specify the destination path for generated Python code. | +| [`--schema-version`](#schema-version) | Schema version to use for parsing. | +| [`--schema-version-mode`](#schema-version-mode) | Schema version validation mode. | | [`--url`](#url) | Fetch schema from URL with custom HTTP headers. | --- @@ -326,6 +328,76 @@ is written to stdout. --- +## `--schema-version` {#schema-version} + +Schema version to use for parsing. + +The `--schema-version` option specifies the schema version to use instead of auto-detection. +Valid values depend on input type: JsonSchema (draft-04, draft-06, draft-07, 2019-09, 2020-12) +or OpenAPI (3.0, 3.1). Default is 'auto' (detected from $schema or openapi field). + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --schema-version draft-07 # (1)! + ``` + + 1. :material-arrow-left: `--schema-version` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": {"s": {"type": ["string"]}}, + "required": ["s"] + } + ``` + + **Output:** + + > **Error:** File not found: jsonschema/simple_string.py + +--- + +## `--schema-version-mode` {#schema-version-mode} + +Schema version validation mode. + +The `--schema-version-mode` option controls how schema version validation is performed. +'lenient' (default): accept all features regardless of version. +'strict': warn on features outside the declared/detected version. + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --schema-version-mode lenient # (1)! + ``` + + 1. :material-arrow-left: `--schema-version-mode` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": {"s": {"type": ["string"]}}, + "required": ["s"] + } + ``` + + **Output:** + + > **Error:** File not found: jsonschema/simple_string.py + +--- + ## `--url` {#url} Fetch schema from URL with custom HTTP headers. diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index cd96c3d34..0f7c2ef64 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -8,7 +8,7 @@ This documentation is auto-generated from test cases. | Category | Options | Description | |----------|---------|-------------| -| 📁 [Base Options](base-options.md) | 7 | Input/output configuration | +| 📁 [Base Options](base-options.md) | 9 | Input/output configuration | | 🔧 [Typing Customization](typing-customization.md) | 27 | Type annotation and import behavior | | 🏷️ [Field Customization](field-customization.md) | 24 | Field naming and docstring behavior | | 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior | @@ -161,6 +161,8 @@ This documentation is auto-generated from test cases. ### S {#s} +- [`--schema-version`](base-options.md#schema-version) +- [`--schema-version-mode`](base-options.md#schema-version-mode) - [`--set-default-enum-member`](field-customization.md#set-default-enum-member) - [`--shared-module-name`](general-options.md#shared-module-name) - [`--skip-root-model`](model-customization.md#skip-root-model) diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index d9e57eaf9..d4f4c6499 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -22,6 +22,8 @@ datamodel-codegen [OPTIONS] | [`--input-model`](base-options.md#input-model) | Import a Python type or dict schema from a module. | | [`--input-model-ref-strategy`](base-options.md#input-model-ref-strategy) | Strategy for referenced types when using --input-model. | | [`--output`](base-options.md#output) | Specify the destination path for generated Python code. | +| [`--schema-version`](base-options.md#schema-version) | Schema version to use for parsing. | +| [`--schema-version-mode`](base-options.md#schema-version-mode) | Schema version validation mode. | | [`--url`](base-options.md#url) | Fetch schema from URL with custom HTTP headers. | ### 🔧 Typing Customization @@ -299,6 +301,8 @@ All options sorted alphabetically: - [`--remove-special-field-name-prefix`](field-customization.md#remove-special-field-name-prefix) - Remove the special prefix from field names. - [`--reuse-model`](model-customization.md#reuse-model) - Reuse identical model definitions instead of generating dupl... - [`--reuse-scope`](model-customization.md#reuse-scope) - Scope for model reuse detection (root or tree). +- [`--schema-version`](base-options.md#schema-version) - Schema version to use for parsing. +- [`--schema-version-mode`](base-options.md#schema-version-mode) - Schema version validation mode. - [`--set-default-enum-member`](field-customization.md#set-default-enum-member) - Set the first enum member as the default value for enum fiel... - [`--shared-module-name`](general-options.md#shared-module-name) - Customize the name of the shared module for deduplicated mod... - [`--skip-root-model`](model-customization.md#skip-root-model) - Skip generation of root model when schema contains nested de... From 7a75adffbb1bf7f7151b1d17e70798d18ef5cb96 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Mon, 5 Jan 2026 17:43:41 +0000 Subject: [PATCH 4/7] Add version-specific schema processing using schema_features --- .../parser/jsonschema.py | 19 +++++++++++++++---- .../parser/openapi.py | 7 ++++--- .../parser/schema_version.py | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 53a933024..6368f5773 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -192,7 +192,12 @@ class JSONReference(_enum.Enum): class Discriminator(BaseModel): - """Represent OpenAPI discriminator object.""" + """Represent OpenAPI discriminator object. + + This is an OpenAPI-specific concept for supporting polymorphism. + It identifies which schema applies based on a property value. + Kept in jsonschema.py to avoid circular imports with openapi.py. + """ propertyName: str # noqa: N815 mapping: Optional[dict[str, str]] = None # noqa: UP045 @@ -517,7 +522,7 @@ def _get_type( data_formats: dict[str, dict[str, Types]] | None = None, ) -> Types: """Get the appropriate Types enum for a given JSON Schema type and format.""" - if data_formats is None: + if data_formats is None: # pragma: no cover data_formats = json_schema_data_formats if type_ not in data_formats: return Types.any @@ -3592,9 +3597,15 @@ def parse_id(self, obj: JsonSchemaObject, path: list[str]) -> None: @contextmanager def root_id_context(self, root_raw: dict[str, Any]) -> Generator[None, None, None]: - """Context manager to temporarily set the root $id during parsing.""" + """Context manager to temporarily set the root $id during parsing. + + Uses schema_features.id_field to support both "id" (Draft 4) and "$id" (Draft 6+). + Falls back to checking both fields for lenient compatibility. + """ previous_root_id = self.root_id - self.root_id = root_raw.get("$id") or None + # Try version-specific field first, then fallback to alternative for compatibility + id_field = self.schema_features.id_field + self.root_id = root_raw.get(id_field) or root_raw.get("$id") or root_raw.get("id") or None yield self.root_id = previous_root_id diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index 99916ac51..05cac886d 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -243,10 +243,11 @@ def get_ref_model(self, ref: str) -> dict[str, Any]: def get_data_type(self, obj: JsonSchemaObject) -> DataType: """Get data type from JSON schema object, handling OpenAPI nullable semantics.""" - # OpenAPI 3.0 doesn't allow `null` in the `type` field and list of types + # OpenAPI 3.0 uses `nullable: true` flag for null support (nullable_keyword=True) + # OpenAPI 3.1 uses `type: ["string", "null"]` instead (nullable_keyword=False) # https://swagger.io/docs/specification/data-models/data-types/#null - # OpenAPI 3.1 does allow `null` in the `type` field and is equivalent to - # a `nullable` flag on the property itself + # When strict_nullable is enabled, convert nullable flag to type array for + # consistent handling regardless of OpenAPI version if obj.nullable and self.strict_nullable and isinstance(obj.type, str): obj.type = [obj.type, "null"] diff --git a/src/datamodel_code_generator/parser/schema_version.py b/src/datamodel_code_generator/parser/schema_version.py index 7e8b71ca0..7b4ab71b0 100644 --- a/src/datamodel_code_generator/parser/schema_version.py +++ b/src/datamodel_code_generator/parser/schema_version.py @@ -272,7 +272,7 @@ def get_data_formats(*, is_openapi: bool = False) -> DataFormatMapping: for type_key, type_formats in _get_openapi_only_formats().items(): if type_key in formats: formats[type_key] = {**formats[type_key], **type_formats} - else: + else: # pragma: no cover formats[type_key] = type_formats return formats From 152badf89088a0fdee07cbc05fd7d103d07bcb9c Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 6 Jan 2026 00:56:31 +0000 Subject: [PATCH 5/7] Implement flag-based behavior control for schema version --- .../_types/parser_config_dicts.py | 2 + src/datamodel_code_generator/config.py | 1 + .../parser/jsonschema.py | 30 +++- .../parser/openapi.py | 33 +++-- tests/parser/test_schema_version.py | 130 ++++++++++++++++++ 5 files changed, 186 insertions(+), 10 deletions(-) diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index 6b8cee410..e61597c25 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -27,6 +27,7 @@ ReuseScope, StrictTypes, TargetPydanticVersion, + VersionMode, ) from datamodel_code_generator.format import DateClassType, DatetimeClassType, Formatter, PythonVersion from datamodel_code_generator.model.base import DataModel, DataModelFieldBase @@ -170,6 +171,7 @@ class GraphQLParserConfigDict(ParserConfigDict, closed=True): class JSONSchemaParserConfigDict(ParserConfigDict): jsonschema_version: NotRequired[JsonSchemaVersion | None] + schema_version_mode: NotRequired[VersionMode | None] class OpenAPIParserConfigDict(JSONSchemaParserConfigDict, closed=True): diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index 501308a21..c91d51c52 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -356,6 +356,7 @@ class JSONSchemaParserConfig(ParserConfig): """Configuration model for JsonSchemaParser.__init__().""" jsonschema_version: JsonSchemaVersion | None = None + schema_version_mode: VersionMode | None = None class OpenAPIParserConfig(JSONSchemaParserConfig): diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 6368f5773..aaabc84cb 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -773,8 +773,34 @@ def _get_type_with_mappings(self, type_: str, format_: str | None = None) -> Typ @cached_property def schema_paths(self) -> list[tuple[str, list[str]]]: - """Get schema paths for definitions and defs.""" - return [(s, s.lstrip("#/").split("/")) for s in self.SCHEMA_PATHS] + """Get schema paths for definitions and defs. + + For JsonSchema, uses schema_features.definitions_key to determine + the primary path, with fallback to the alternative in Lenient mode. + OpenAPI subclass uses its own SCHEMA_PATHS (#/components/schemas). + """ + from datamodel_code_generator.enums import VersionMode # noqa: PLC0415 + + # OpenAPI and other subclasses use their own SCHEMA_PATHS + if self.SCHEMA_PATHS != ["#/definitions", "#/$defs"]: + return [(s, s.lstrip("#/").split("/")) for s in self.SCHEMA_PATHS] + + # JsonSchema: use definitions_key from schema_features + primary_key = self.schema_features.definitions_key + primary_path = f"#/{primary_key}" + fallback_key = "$defs" if primary_key == "definitions" else "definitions" + fallback_path = f"#/{fallback_key}" + + # Strict mode: only use version-specific path + version_mode = getattr(self.config, "schema_version_mode", None) + if version_mode == VersionMode.Strict: + return [(str(primary_path), [str(primary_key)])] + + # Lenient mode (default): check both paths, primary first + return [ + (str(primary_path), [str(primary_key)]), + (str(fallback_path), [str(fallback_key)]), + ] @cached_property def schema_features(self) -> JsonSchemaFeatures: diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index 05cac886d..2c1526869 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -242,14 +242,31 @@ def get_ref_model(self, ref: str) -> dict[str, Any]: return get_model_by_path(ref_body, ref_path.split("/")[1:]) def get_data_type(self, obj: JsonSchemaObject) -> DataType: - """Get data type from JSON schema object, handling OpenAPI nullable semantics.""" - # OpenAPI 3.0 uses `nullable: true` flag for null support (nullable_keyword=True) - # OpenAPI 3.1 uses `type: ["string", "null"]` instead (nullable_keyword=False) - # https://swagger.io/docs/specification/data-models/data-types/#null - # When strict_nullable is enabled, convert nullable flag to type array for - # consistent handling regardless of OpenAPI version - if obj.nullable and self.strict_nullable and isinstance(obj.type, str): - obj.type = [obj.type, "null"] + """Get data type from JSON schema object, handling OpenAPI nullable semantics. + + Uses schema_features.nullable_keyword to handle version differences: + - OpenAPI 3.0: nullable: true is valid, convert to type array when strict_nullable + - OpenAPI 3.1: nullable is deprecated, use type: ["string", "null"] instead + """ + from datamodel_code_generator.enums import VersionMode # noqa: PLC0415 + + if obj.nullable: + if self.schema_features.nullable_keyword: + # OpenAPI 3.0: nullable: true is the standard way + if self.strict_nullable and isinstance(obj.type, str): + obj.type = [obj.type, "null"] + else: + # OpenAPI 3.1+: nullable is deprecated, still process but warn in Strict mode + version_mode = getattr(self.config, "schema_version_mode", None) + if version_mode == VersionMode.Strict: + warn( + 'nullable keyword is deprecated in OpenAPI 3.1, use type: ["string", "null"] instead', + DeprecationWarning, + stacklevel=2, + ) + # Still convert to type array for compatibility + if self.strict_nullable and isinstance(obj.type, str): + obj.type = [obj.type, "null"] return super().get_data_type(obj) diff --git a/tests/parser/test_schema_version.py b/tests/parser/test_schema_version.py index f073f00a3..b12408958 100644 --- a/tests/parser/test_schema_version.py +++ b/tests/parser/test_schema_version.py @@ -495,3 +495,133 @@ def test_cli_schema_version_mode() -> None: schema_version_mode=VersionMode.Lenient, ) assert result is not None + + +def test_schema_paths_lenient_mode_draft7() -> None: + """Test schema_paths returns both paths in Lenient mode for Draft 7.""" + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser("", jsonschema_version=JsonSchemaVersion.Draft7) + paths = parser.schema_paths + assert paths == snapshot([ + ("#/definitions", ["definitions"]), + ("#/$defs", ["$defs"]), + ]) + + +def test_schema_paths_lenient_mode_2020_12() -> None: + """Test schema_paths returns $defs first in Lenient mode for 2020-12.""" + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser("", jsonschema_version=JsonSchemaVersion.Draft202012) + paths = parser.schema_paths + assert paths == snapshot([ + ("#/$defs", ["$defs"]), + ("#/definitions", ["definitions"]), + ]) + + +def test_schema_paths_strict_mode_draft7() -> None: + """Test schema_paths returns only definitions in Strict mode for Draft 7.""" + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft7, + schema_version_mode=VersionMode.Strict, + ) + paths = parser.schema_paths + assert paths == snapshot([("#/definitions", ["definitions"])]) + + +def test_schema_paths_strict_mode_2020_12() -> None: + """Test schema_paths returns only $defs in Strict mode for 2020-12.""" + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft202012, + schema_version_mode=VersionMode.Strict, + ) + paths = parser.schema_paths + assert paths == snapshot([("#/$defs", ["$defs"])]) + + +def test_openapi_schema_paths_unchanged() -> None: + """Test that OpenAPI schema_paths uses SCHEMA_PATHS regardless of version mode.""" + from datamodel_code_generator.parser.openapi import OpenAPIParser + + parser = OpenAPIParser( + "", + openapi_version=OpenAPIVersion.V31, + schema_version_mode=VersionMode.Strict, + ) + paths = parser.schema_paths + assert paths == snapshot([("#/components/schemas", ["components", "schemas"])]) + + +def test_nullable_keyword_openapi_31_strict_warning() -> None: + """Test that nullable keyword emits warning in OpenAPI 3.1 Strict mode.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaObject + from datamodel_code_generator.parser.openapi import OpenAPIParser + + parser = OpenAPIParser( + "", + openapi_version=OpenAPIVersion.V31, + schema_version_mode=VersionMode.Strict, + strict_nullable=True, + ) + obj = JsonSchemaObject(type="string", nullable=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser.get_data_type(obj) + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "nullable keyword is deprecated" in str(w[0].message) + + +def test_nullable_keyword_openapi_30_no_warning() -> None: + """Test that nullable keyword does NOT emit warning in OpenAPI 3.0.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaObject + from datamodel_code_generator.parser.openapi import OpenAPIParser + + parser = OpenAPIParser( + "", + openapi_version=OpenAPIVersion.V30, + schema_version_mode=VersionMode.Strict, + strict_nullable=True, + ) + obj = JsonSchemaObject(type="string", nullable=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser.get_data_type(obj) + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 0 + + +def test_nullable_keyword_openapi_31_lenient_no_warning() -> None: + """Test that nullable keyword does NOT emit warning in OpenAPI 3.1 Lenient mode.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaObject + from datamodel_code_generator.parser.openapi import OpenAPIParser + + parser = OpenAPIParser( + "", + openapi_version=OpenAPIVersion.V31, + schema_version_mode=VersionMode.Lenient, + strict_nullable=True, + ) + obj = JsonSchemaObject(type="string", nullable=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser.get_data_type(obj) + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 0 From 65bdcee2669951567921ee5d676c48419e997f9e Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 6 Jan 2026 01:16:12 +0000 Subject: [PATCH 6/7] Add comprehensive version-specific feature checks with exclusive_as_number flag --- .../parser/jsonschema.py | 108 ++++++++- .../parser/openapi.py | 4 +- .../parser/schema_version.py | 14 ++ tests/parser/test_schema_version.py | 206 +++++++++++++++++- 4 files changed, 326 insertions(+), 6 deletions(-) diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index aaabc84cb..07e8828f4 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -31,6 +31,7 @@ JsonSchemaVersion, ReadOnlyWriteOnlyModelType, SchemaParseError, + VersionMode, YamlValue, load_data, load_data_from_path, @@ -779,8 +780,6 @@ def schema_paths(self) -> list[tuple[str, list[str]]]: the primary path, with fallback to the alternative in Lenient mode. OpenAPI subclass uses its own SCHEMA_PATHS (#/components/schemas). """ - from datamodel_code_generator.enums import VersionMode # noqa: PLC0415 - # OpenAPI and other subclasses use their own SCHEMA_PATHS if self.SCHEMA_PATHS != ["#/definitions", "#/$defs"]: return [(s, s.lstrip("#/").split("/")) for s in self.SCHEMA_PATHS] @@ -2952,6 +2951,9 @@ def parse_array_fields( # noqa: PLR0912 singular_name: bool = True, # noqa: FBT001, FBT002 ) -> DataModelFieldBase: """Parse array schema into a data model field with list type.""" + # Strict mode: check for version-specific array features + self._check_array_version_features(obj, path) + if self.force_optional_for_required_fields: required: bool = False nullable: Optional[bool] = None # noqa: UP045 @@ -3662,9 +3664,111 @@ def parse_raw_obj( if isinstance(raw, dict) and "x-python-import" in raw: self._handle_python_import(name, path) return + + # Strict mode: check for version-specific features before validation + self._check_version_specific_features(raw, path) + obj = self._validate_schema_object(raw, path) self.parse_obj(name, obj, path) + def _check_version_specific_features( + self, + raw: dict[str, YamlValue] | YamlValue, + path: list[str], + ) -> None: + """Check for version-specific features and warn in Strict mode. + + This method checks the raw schema data before Pydantic validation + to detect features that may not be valid for the declared version. + """ + version_mode = getattr(self.config, "schema_version_mode", None) + if version_mode != VersionMode.Strict: + return + + # Check boolean schemas (Draft 6+) + if isinstance(raw, bool): + if not self.schema_features.boolean_schemas: + version_name = "Draft 4" if self.schema_features.id_field == "id" else "this version" + warn( + f"Boolean schemas are not supported in {version_name}. Schema path: {'/'.join(path)}", + stacklevel=3, + ) + return + + if not isinstance(raw, dict): + return + + # Check null in type array (Draft 2020-12 / OpenAPI 3.1+) + type_value = raw.get("type") + if isinstance(type_value, list) and "null" in type_value and not self.schema_features.null_in_type_array: + warn( + 'null in type array (e.g., type: ["string", "null"]) is not supported ' + f"in this schema version. Use nullable: true instead. Schema path: {'/'.join(path)}", + stacklevel=3, + ) + + # Check exclusive min/max format (Draft 4 uses boolean, Draft 6+ uses number) + exclusive_min = raw.get("exclusiveMinimum") + exclusive_max = raw.get("exclusiveMaximum") + if self.schema_features.exclusive_as_number: + # Draft 6+: should be numeric, not boolean + if isinstance(exclusive_min, bool): + warn( + f"exclusiveMinimum as boolean is Draft 4 style, but schema version uses numeric style. " + f"Schema path: {'/'.join(path)}", + stacklevel=3, + ) + if isinstance(exclusive_max, bool): + warn( + f"exclusiveMaximum as boolean is Draft 4 style, but schema version uses numeric style. " + f"Schema path: {'/'.join(path)}", + stacklevel=3, + ) + else: + # Draft 4: should be boolean, not numeric + if exclusive_min is not None and not isinstance(exclusive_min, bool): + warn( + f"exclusiveMinimum as number is Draft 6+ style, but schema version is Draft 4. " + f"Schema path: {'/'.join(path)}", + stacklevel=3, + ) + if exclusive_max is not None and not isinstance(exclusive_max, bool): + warn( + f"exclusiveMaximum as number is Draft 6+ style, but schema version is Draft 4. " + f"Schema path: {'/'.join(path)}", + stacklevel=3, + ) + + def _check_array_version_features( + self, + obj: JsonSchemaObject, + path: list[str], + ) -> None: + """Check for version-specific array features and warn in Strict mode. + + Warns when prefixItems is used in versions that don't support it, + or when items as array (tuple style) is used in Draft 2020-12+. + """ + version_mode = getattr(self.config, "schema_version_mode", None) + if version_mode != VersionMode.Strict: + return + + # Check prefixItems usage (Draft 2020-12+ only) + if obj.prefixItems is not None and not self.schema_features.prefix_items: + warn( + f"prefixItems is not supported in this schema version. " + f"Use items as array for tuple validation. Schema path: {'/'.join(path)}", + stacklevel=4, + ) + + # Check items as array usage (deprecated in Draft 2020-12) + if isinstance(obj.items, list) and self.schema_features.prefix_items: + warn( + f"items as array (tuple validation) is deprecated in Draft 2020-12. " + f"Use prefixItems instead. Schema path: {'/'.join(path)}", + stacklevel=4, + ) + def _handle_python_import( self, name: str, diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index 2c1526869..c89c038cc 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -27,7 +27,7 @@ load_data, snooper_to_methods, ) -from datamodel_code_generator.enums import OpenAPIVersion +from datamodel_code_generator.enums import OpenAPIVersion, VersionMode from datamodel_code_generator.parser.base import get_special_path from datamodel_code_generator.parser.jsonschema import ( JsonSchemaObject, @@ -248,8 +248,6 @@ def get_data_type(self, obj: JsonSchemaObject) -> DataType: - OpenAPI 3.0: nullable: true is valid, convert to type array when strict_nullable - OpenAPI 3.1: nullable is deprecated, use type: ["string", "null"] instead """ - from datamodel_code_generator.enums import VersionMode # noqa: PLC0415 - if obj.nullable: if self.schema_features.nullable_keyword: # OpenAPI 3.0: nullable: true is the standard way diff --git a/src/datamodel_code_generator/parser/schema_version.py b/src/datamodel_code_generator/parser/schema_version.py index 7b4ab71b0..96d9156b4 100644 --- a/src/datamodel_code_generator/parser/schema_version.py +++ b/src/datamodel_code_generator/parser/schema_version.py @@ -30,6 +30,7 @@ class JsonSchemaFeatures: boolean_schemas: Draft 6+ allows boolean values as schemas. id_field: The field name for schema ID ("id" for Draft 4, "$id" for Draft 6+). definitions_key: The key for definitions ("definitions" or "$defs"). + exclusive_as_number: Draft 6+ uses numeric exclusiveMin/Max (Draft 4 uses boolean). """ null_in_type_array: bool @@ -38,6 +39,7 @@ class JsonSchemaFeatures: boolean_schemas: bool id_field: str definitions_key: str + exclusive_as_number: bool @classmethod def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: @@ -51,6 +53,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: boolean_schemas=False, id_field="id", definitions_key="definitions", + exclusive_as_number=False, ) case JsonSchemaVersion.Draft6 | JsonSchemaVersion.Draft7: return cls( @@ -60,6 +63,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: boolean_schemas=True, id_field="$id", definitions_key="definitions", + exclusive_as_number=True, ) case JsonSchemaVersion.Draft201909: return cls( @@ -69,6 +73,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: boolean_schemas=True, id_field="$id", definitions_key="$defs", + exclusive_as_number=True, ) case _: return cls( @@ -78,6 +83,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: boolean_schemas=True, id_field="$id", definitions_key="$defs", + exclusive_as_number=True, ) @@ -100,6 +106,8 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures: """Create OpenAPISchemaFeatures from an OpenAPI version.""" match version: case OpenAPIVersion.V30: + # OpenAPI 3.0 schema dialect inherits JSON Schema Draft 4 semantics + # where exclusiveMinimum/Maximum are boolean values return cls( null_in_type_array=False, defs_not_definitions=False, @@ -107,6 +115,7 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures: boolean_schemas=False, id_field="$id", definitions_key="definitions", + exclusive_as_number=False, nullable_keyword=True, discriminator_support=True, ) @@ -118,6 +127,7 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures: boolean_schemas=True, id_field="$id", definitions_key="$defs", + exclusive_as_number=True, nullable_keyword=False, discriminator_support=True, ) @@ -158,6 +168,10 @@ def detect_jsonschema_version(data: dict[str, Any]) -> JsonSchemaVersion: if pattern in schema_url: return version + # Heuristic detection based on keywords + # $defs was introduced in Draft 2019-09, but Draft 2020-12 also uses it. + # Since 2020-12 is a superset of 2019-09, default to 2020-12 when $defs is present + # to avoid false warnings in Strict mode for features valid in both versions. if "$defs" in data: return JsonSchemaVersion.Draft202012 if "definitions" in data: diff --git a/tests/parser/test_schema_version.py b/tests/parser/test_schema_version.py index b12408958..2fddf861c 100644 --- a/tests/parser/test_schema_version.py +++ b/tests/parser/test_schema_version.py @@ -56,7 +56,11 @@ def test_detect_jsonschema_version_2020_12() -> None: def test_detect_jsonschema_version_defs_heuristic() -> None: - """Test detection using $defs heuristic defaults to latest.""" + """Test detection using $defs heuristic. + + $defs was introduced in Draft 2019-09, but Draft 2020-12 also uses it. + Since 2020-12 is a superset, default to 2020-12 to avoid false warnings. + """ assert detect_jsonschema_version({"$defs": {"Foo": {"type": "string"}}}) == snapshot(JsonSchemaVersion.Draft202012) @@ -110,6 +114,7 @@ def test_jsonschema_features_draft4() -> None: boolean_schemas=False, id_field="id", definitions_key="definitions", + exclusive_as_number=False, ) ) @@ -124,6 +129,7 @@ def test_jsonschema_features_draft6() -> None: boolean_schemas=True, id_field="$id", definitions_key="definitions", + exclusive_as_number=True, ) ) @@ -138,6 +144,7 @@ def test_jsonschema_features_draft7() -> None: boolean_schemas=True, id_field="$id", definitions_key="definitions", + exclusive_as_number=True, ) ) @@ -152,6 +159,7 @@ def test_jsonschema_features_2019_09() -> None: boolean_schemas=True, id_field="$id", definitions_key="$defs", + exclusive_as_number=True, ) ) @@ -166,6 +174,7 @@ def test_jsonschema_features_2020_12() -> None: boolean_schemas=True, id_field="$id", definitions_key="$defs", + exclusive_as_number=True, ) ) @@ -180,6 +189,7 @@ def test_jsonschema_features_auto() -> None: boolean_schemas=True, id_field="$id", definitions_key="$defs", + exclusive_as_number=True, ) ) @@ -201,6 +211,7 @@ def test_openapi_features_v30() -> None: boolean_schemas=False, id_field="$id", definitions_key="definitions", + exclusive_as_number=False, nullable_keyword=True, discriminator_support=True, ) @@ -217,6 +228,7 @@ def test_openapi_features_v31() -> None: boolean_schemas=True, id_field="$id", definitions_key="$defs", + exclusive_as_number=True, nullable_keyword=False, discriminator_support=True, ) @@ -233,6 +245,7 @@ def test_openapi_features_auto() -> None: boolean_schemas=True, id_field="$id", definitions_key="$defs", + exclusive_as_number=True, nullable_keyword=False, discriminator_support=True, ) @@ -625,3 +638,194 @@ def test_nullable_keyword_openapi_31_lenient_no_warning() -> None: parser.get_data_type(obj) deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] assert len(deprecation_warnings) == 0 + + +def test_null_in_type_array_strict_warning_draft7() -> None: + """Test that null in type array emits warning in Draft 7 Strict mode.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft7, + schema_version_mode=VersionMode.Strict, + ) + raw_schema = {"type": ["string", "null"]} + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser._check_version_specific_features(raw_schema, ["test"]) + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 1 + assert "null in type array" in str(user_warnings[0].message) + + +def test_null_in_type_array_no_warning_2020_12() -> None: + """Test that null in type array does NOT emit warning in Draft 2020-12.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft202012, + schema_version_mode=VersionMode.Strict, + ) + raw_schema = {"type": ["string", "null"]} + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser._check_version_specific_features(raw_schema, ["test"]) + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 0 + + +def test_exclusive_as_number_strict_warning_draft4() -> None: + """Test that numeric exclusiveMinimum emits warning in Draft 4 Strict mode.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft4, + schema_version_mode=VersionMode.Strict, + ) + raw_schema = {"type": "number", "exclusiveMinimum": 5} + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser._check_version_specific_features(raw_schema, ["test"]) + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 1 + assert "exclusiveMinimum as number" in str(user_warnings[0].message) + + +def test_exclusive_as_bool_strict_warning_draft7() -> None: + """Test that boolean exclusiveMinimum emits warning in Draft 7 Strict mode.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft7, + schema_version_mode=VersionMode.Strict, + ) + raw_schema = {"type": "number", "minimum": 5, "exclusiveMinimum": True} + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser._check_version_specific_features(raw_schema, ["test"]) + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 1 + assert "exclusiveMinimum as boolean" in str(user_warnings[0].message) + + +def test_prefix_items_strict_warning_draft7() -> None: + """Test that prefixItems emits warning in Draft 7 Strict mode.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaObject, JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft7, + schema_version_mode=VersionMode.Strict, + ) + obj = JsonSchemaObject( + type="array", + prefixItems=[JsonSchemaObject(type="string"), JsonSchemaObject(type="number")], + ) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser._check_array_version_features(obj, ["test"]) + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 1 + assert "prefixItems is not supported" in str(user_warnings[0].message) + + +def test_items_array_strict_warning_2020_12() -> None: + """Test that items as array emits warning in Draft 2020-12 Strict mode.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaObject, JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft202012, + schema_version_mode=VersionMode.Strict, + ) + obj = JsonSchemaObject( + type="array", + items=[JsonSchemaObject(type="string"), JsonSchemaObject(type="number")], + ) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser._check_array_version_features(obj, ["test"]) + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 1 + assert "items as array" in str(user_warnings[0].message) + + +def test_boolean_schema_strict_warning_draft4() -> None: + """Test that boolean schema emits warning in Draft 4 Strict mode.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft4, + schema_version_mode=VersionMode.Strict, + ) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser._check_version_specific_features(True, ["test"]) + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 1 + assert "Boolean schemas" in str(user_warnings[0].message) + + +def test_boolean_schema_no_warning_draft7() -> None: + """Test that boolean schema does NOT emit warning in Draft 7.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft7, + schema_version_mode=VersionMode.Strict, + ) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser._check_version_specific_features(True, ["test"]) + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 0 + + +def test_version_checks_lenient_no_warnings() -> None: + """Test that version checks do NOT emit warnings in Lenient mode.""" + import warnings + + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser + + parser = JsonSchemaParser( + "", + jsonschema_version=JsonSchemaVersion.Draft4, + schema_version_mode=VersionMode.Lenient, + ) + raw_schema = {"type": ["string", "null"], "exclusiveMinimum": 5} + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + parser._check_version_specific_features(raw_schema, ["test"]) + parser._check_version_specific_features(True, ["test"]) + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 0 From dd2aef291e0280053c1716ea6cb7a2073c304531 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 6 Jan 2026 15:36:48 +0000 Subject: [PATCH 7/7] Replace getattr with direct config access for schema_version_mode --- src/datamodel_code_generator/parser/jsonschema.py | 9 +++------ src/datamodel_code_generator/parser/openapi.py | 3 +-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 07e8828f4..32312cf67 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -791,8 +791,7 @@ def schema_paths(self) -> list[tuple[str, list[str]]]: fallback_path = f"#/{fallback_key}" # Strict mode: only use version-specific path - version_mode = getattr(self.config, "schema_version_mode", None) - if version_mode == VersionMode.Strict: + if self.config.schema_version_mode == VersionMode.Strict: return [(str(primary_path), [str(primary_key)])] # Lenient mode (default): check both paths, primary first @@ -3681,8 +3680,7 @@ def _check_version_specific_features( This method checks the raw schema data before Pydantic validation to detect features that may not be valid for the declared version. """ - version_mode = getattr(self.config, "schema_version_mode", None) - if version_mode != VersionMode.Strict: + if self.config.schema_version_mode != VersionMode.Strict: return # Check boolean schemas (Draft 6+) @@ -3749,8 +3747,7 @@ def _check_array_version_features( Warns when prefixItems is used in versions that don't support it, or when items as array (tuple style) is used in Draft 2020-12+. """ - version_mode = getattr(self.config, "schema_version_mode", None) - if version_mode != VersionMode.Strict: + if self.config.schema_version_mode != VersionMode.Strict: return # Check prefixItems usage (Draft 2020-12+ only) diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index c89c038cc..4b86f8908 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -255,8 +255,7 @@ def get_data_type(self, obj: JsonSchemaObject) -> DataType: obj.type = [obj.type, "null"] else: # OpenAPI 3.1+: nullable is deprecated, still process but warn in Strict mode - version_mode = getattr(self.config, "schema_version_mode", None) - if version_mode == VersionMode.Strict: + if self.config.schema_version_mode == VersionMode.Strict: warn( 'nullable keyword is deprecated in OpenAPI 3.1, use type: ["string", "null"] instead', DeprecationWarning,