Skip to content

Commit 152badf

Browse files
committed
Implement flag-based behavior control for schema version
1 parent 7a75adf commit 152badf

5 files changed

Lines changed: 186 additions & 10 deletions

File tree

src/datamodel_code_generator/_types/parser_config_dicts.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ReuseScope,
2828
StrictTypes,
2929
TargetPydanticVersion,
30+
VersionMode,
3031
)
3132
from datamodel_code_generator.format import DateClassType, DatetimeClassType, Formatter, PythonVersion
3233
from datamodel_code_generator.model.base import DataModel, DataModelFieldBase
@@ -170,6 +171,7 @@ class GraphQLParserConfigDict(ParserConfigDict, closed=True):
170171

171172
class JSONSchemaParserConfigDict(ParserConfigDict):
172173
jsonschema_version: NotRequired[JsonSchemaVersion | None]
174+
schema_version_mode: NotRequired[VersionMode | None]
173175

174176

175177
class OpenAPIParserConfigDict(JSONSchemaParserConfigDict, closed=True):

src/datamodel_code_generator/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ class JSONSchemaParserConfig(ParserConfig):
356356
"""Configuration model for JsonSchemaParser.__init__()."""
357357

358358
jsonschema_version: JsonSchemaVersion | None = None
359+
schema_version_mode: VersionMode | None = None
359360

360361

361362
class OpenAPIParserConfig(JSONSchemaParserConfig):

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -773,8 +773,34 @@ def _get_type_with_mappings(self, type_: str, format_: str | None = None) -> Typ
773773

774774
@cached_property
775775
def schema_paths(self) -> list[tuple[str, list[str]]]:
776-
"""Get schema paths for definitions and defs."""
777-
return [(s, s.lstrip("#/").split("/")) for s in self.SCHEMA_PATHS]
776+
"""Get schema paths for definitions and defs.
777+
778+
For JsonSchema, uses schema_features.definitions_key to determine
779+
the primary path, with fallback to the alternative in Lenient mode.
780+
OpenAPI subclass uses its own SCHEMA_PATHS (#/components/schemas).
781+
"""
782+
from datamodel_code_generator.enums import VersionMode # noqa: PLC0415
783+
784+
# OpenAPI and other subclasses use their own SCHEMA_PATHS
785+
if self.SCHEMA_PATHS != ["#/definitions", "#/$defs"]:
786+
return [(s, s.lstrip("#/").split("/")) for s in self.SCHEMA_PATHS]
787+
788+
# JsonSchema: use definitions_key from schema_features
789+
primary_key = self.schema_features.definitions_key
790+
primary_path = f"#/{primary_key}"
791+
fallback_key = "$defs" if primary_key == "definitions" else "definitions"
792+
fallback_path = f"#/{fallback_key}"
793+
794+
# Strict mode: only use version-specific path
795+
version_mode = getattr(self.config, "schema_version_mode", None)
796+
if version_mode == VersionMode.Strict:
797+
return [(str(primary_path), [str(primary_key)])]
798+
799+
# Lenient mode (default): check both paths, primary first
800+
return [
801+
(str(primary_path), [str(primary_key)]),
802+
(str(fallback_path), [str(fallback_key)]),
803+
]
778804

779805
@cached_property
780806
def schema_features(self) -> JsonSchemaFeatures:

src/datamodel_code_generator/parser/openapi.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -242,14 +242,31 @@ def get_ref_model(self, ref: str) -> dict[str, Any]:
242242
return get_model_by_path(ref_body, ref_path.split("/")[1:])
243243

244244
def get_data_type(self, obj: JsonSchemaObject) -> DataType:
245-
"""Get data type from JSON schema object, handling OpenAPI nullable semantics."""
246-
# OpenAPI 3.0 uses `nullable: true` flag for null support (nullable_keyword=True)
247-
# OpenAPI 3.1 uses `type: ["string", "null"]` instead (nullable_keyword=False)
248-
# https://swagger.io/docs/specification/data-models/data-types/#null
249-
# When strict_nullable is enabled, convert nullable flag to type array for
250-
# consistent handling regardless of OpenAPI version
251-
if obj.nullable and self.strict_nullable and isinstance(obj.type, str):
252-
obj.type = [obj.type, "null"]
245+
"""Get data type from JSON schema object, handling OpenAPI nullable semantics.
246+
247+
Uses schema_features.nullable_keyword to handle version differences:
248+
- OpenAPI 3.0: nullable: true is valid, convert to type array when strict_nullable
249+
- OpenAPI 3.1: nullable is deprecated, use type: ["string", "null"] instead
250+
"""
251+
from datamodel_code_generator.enums import VersionMode # noqa: PLC0415
252+
253+
if obj.nullable:
254+
if self.schema_features.nullable_keyword:
255+
# OpenAPI 3.0: nullable: true is the standard way
256+
if self.strict_nullable and isinstance(obj.type, str):
257+
obj.type = [obj.type, "null"]
258+
else:
259+
# OpenAPI 3.1+: nullable is deprecated, still process but warn in Strict mode
260+
version_mode = getattr(self.config, "schema_version_mode", None)
261+
if version_mode == VersionMode.Strict:
262+
warn(
263+
'nullable keyword is deprecated in OpenAPI 3.1, use type: ["string", "null"] instead',
264+
DeprecationWarning,
265+
stacklevel=2,
266+
)
267+
# Still convert to type array for compatibility
268+
if self.strict_nullable and isinstance(obj.type, str):
269+
obj.type = [obj.type, "null"]
253270

254271
return super().get_data_type(obj)
255272

tests/parser/test_schema_version.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,3 +495,133 @@ def test_cli_schema_version_mode() -> None:
495495
schema_version_mode=VersionMode.Lenient,
496496
)
497497
assert result is not None
498+
499+
500+
def test_schema_paths_lenient_mode_draft7() -> None:
501+
"""Test schema_paths returns both paths in Lenient mode for Draft 7."""
502+
from datamodel_code_generator.parser.jsonschema import JsonSchemaParser
503+
504+
parser = JsonSchemaParser("", jsonschema_version=JsonSchemaVersion.Draft7)
505+
paths = parser.schema_paths
506+
assert paths == snapshot([
507+
("#/definitions", ["definitions"]),
508+
("#/$defs", ["$defs"]),
509+
])
510+
511+
512+
def test_schema_paths_lenient_mode_2020_12() -> None:
513+
"""Test schema_paths returns $defs first in Lenient mode for 2020-12."""
514+
from datamodel_code_generator.parser.jsonschema import JsonSchemaParser
515+
516+
parser = JsonSchemaParser("", jsonschema_version=JsonSchemaVersion.Draft202012)
517+
paths = parser.schema_paths
518+
assert paths == snapshot([
519+
("#/$defs", ["$defs"]),
520+
("#/definitions", ["definitions"]),
521+
])
522+
523+
524+
def test_schema_paths_strict_mode_draft7() -> None:
525+
"""Test schema_paths returns only definitions in Strict mode for Draft 7."""
526+
from datamodel_code_generator.parser.jsonschema import JsonSchemaParser
527+
528+
parser = JsonSchemaParser(
529+
"",
530+
jsonschema_version=JsonSchemaVersion.Draft7,
531+
schema_version_mode=VersionMode.Strict,
532+
)
533+
paths = parser.schema_paths
534+
assert paths == snapshot([("#/definitions", ["definitions"])])
535+
536+
537+
def test_schema_paths_strict_mode_2020_12() -> None:
538+
"""Test schema_paths returns only $defs in Strict mode for 2020-12."""
539+
from datamodel_code_generator.parser.jsonschema import JsonSchemaParser
540+
541+
parser = JsonSchemaParser(
542+
"",
543+
jsonschema_version=JsonSchemaVersion.Draft202012,
544+
schema_version_mode=VersionMode.Strict,
545+
)
546+
paths = parser.schema_paths
547+
assert paths == snapshot([("#/$defs", ["$defs"])])
548+
549+
550+
def test_openapi_schema_paths_unchanged() -> None:
551+
"""Test that OpenAPI schema_paths uses SCHEMA_PATHS regardless of version mode."""
552+
from datamodel_code_generator.parser.openapi import OpenAPIParser
553+
554+
parser = OpenAPIParser(
555+
"",
556+
openapi_version=OpenAPIVersion.V31,
557+
schema_version_mode=VersionMode.Strict,
558+
)
559+
paths = parser.schema_paths
560+
assert paths == snapshot([("#/components/schemas", ["components", "schemas"])])
561+
562+
563+
def test_nullable_keyword_openapi_31_strict_warning() -> None:
564+
"""Test that nullable keyword emits warning in OpenAPI 3.1 Strict mode."""
565+
import warnings
566+
567+
from datamodel_code_generator.parser.jsonschema import JsonSchemaObject
568+
from datamodel_code_generator.parser.openapi import OpenAPIParser
569+
570+
parser = OpenAPIParser(
571+
"",
572+
openapi_version=OpenAPIVersion.V31,
573+
schema_version_mode=VersionMode.Strict,
574+
strict_nullable=True,
575+
)
576+
obj = JsonSchemaObject(type="string", nullable=True)
577+
578+
with warnings.catch_warnings(record=True) as w:
579+
warnings.simplefilter("always")
580+
parser.get_data_type(obj)
581+
assert len(w) == 1
582+
assert issubclass(w[0].category, DeprecationWarning)
583+
assert "nullable keyword is deprecated" in str(w[0].message)
584+
585+
586+
def test_nullable_keyword_openapi_30_no_warning() -> None:
587+
"""Test that nullable keyword does NOT emit warning in OpenAPI 3.0."""
588+
import warnings
589+
590+
from datamodel_code_generator.parser.jsonschema import JsonSchemaObject
591+
from datamodel_code_generator.parser.openapi import OpenAPIParser
592+
593+
parser = OpenAPIParser(
594+
"",
595+
openapi_version=OpenAPIVersion.V30,
596+
schema_version_mode=VersionMode.Strict,
597+
strict_nullable=True,
598+
)
599+
obj = JsonSchemaObject(type="string", nullable=True)
600+
601+
with warnings.catch_warnings(record=True) as w:
602+
warnings.simplefilter("always")
603+
parser.get_data_type(obj)
604+
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
605+
assert len(deprecation_warnings) == 0
606+
607+
608+
def test_nullable_keyword_openapi_31_lenient_no_warning() -> None:
609+
"""Test that nullable keyword does NOT emit warning in OpenAPI 3.1 Lenient mode."""
610+
import warnings
611+
612+
from datamodel_code_generator.parser.jsonschema import JsonSchemaObject
613+
from datamodel_code_generator.parser.openapi import OpenAPIParser
614+
615+
parser = OpenAPIParser(
616+
"",
617+
openapi_version=OpenAPIVersion.V31,
618+
schema_version_mode=VersionMode.Lenient,
619+
strict_nullable=True,
620+
)
621+
obj = JsonSchemaObject(type="string", nullable=True)
622+
623+
with warnings.catch_warnings(record=True) as w:
624+
warnings.simplefilter("always")
625+
parser.get_data_type(obj)
626+
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
627+
assert len(deprecation_warnings) == 0

0 commit comments

Comments
 (0)