Skip to content

Commit 65bdcee

Browse files
committed
Add comprehensive version-specific feature checks with exclusive_as_number flag
1 parent 152badf commit 65bdcee

4 files changed

Lines changed: 326 additions & 6 deletions

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
JsonSchemaVersion,
3232
ReadOnlyWriteOnlyModelType,
3333
SchemaParseError,
34+
VersionMode,
3435
YamlValue,
3536
load_data,
3637
load_data_from_path,
@@ -779,8 +780,6 @@ def schema_paths(self) -> list[tuple[str, list[str]]]:
779780
the primary path, with fallback to the alternative in Lenient mode.
780781
OpenAPI subclass uses its own SCHEMA_PATHS (#/components/schemas).
781782
"""
782-
from datamodel_code_generator.enums import VersionMode # noqa: PLC0415
783-
784783
# OpenAPI and other subclasses use their own SCHEMA_PATHS
785784
if self.SCHEMA_PATHS != ["#/definitions", "#/$defs"]:
786785
return [(s, s.lstrip("#/").split("/")) for s in self.SCHEMA_PATHS]
@@ -2952,6 +2951,9 @@ def parse_array_fields( # noqa: PLR0912
29522951
singular_name: bool = True, # noqa: FBT001, FBT002
29532952
) -> DataModelFieldBase:
29542953
"""Parse array schema into a data model field with list type."""
2954+
# Strict mode: check for version-specific array features
2955+
self._check_array_version_features(obj, path)
2956+
29552957
if self.force_optional_for_required_fields:
29562958
required: bool = False
29572959
nullable: Optional[bool] = None # noqa: UP045
@@ -3662,9 +3664,111 @@ def parse_raw_obj(
36623664
if isinstance(raw, dict) and "x-python-import" in raw:
36633665
self._handle_python_import(name, path)
36643666
return
3667+
3668+
# Strict mode: check for version-specific features before validation
3669+
self._check_version_specific_features(raw, path)
3670+
36653671
obj = self._validate_schema_object(raw, path)
36663672
self.parse_obj(name, obj, path)
36673673

3674+
def _check_version_specific_features(
3675+
self,
3676+
raw: dict[str, YamlValue] | YamlValue,
3677+
path: list[str],
3678+
) -> None:
3679+
"""Check for version-specific features and warn in Strict mode.
3680+
3681+
This method checks the raw schema data before Pydantic validation
3682+
to detect features that may not be valid for the declared version.
3683+
"""
3684+
version_mode = getattr(self.config, "schema_version_mode", None)
3685+
if version_mode != VersionMode.Strict:
3686+
return
3687+
3688+
# Check boolean schemas (Draft 6+)
3689+
if isinstance(raw, bool):
3690+
if not self.schema_features.boolean_schemas:
3691+
version_name = "Draft 4" if self.schema_features.id_field == "id" else "this version"
3692+
warn(
3693+
f"Boolean schemas are not supported in {version_name}. Schema path: {'/'.join(path)}",
3694+
stacklevel=3,
3695+
)
3696+
return
3697+
3698+
if not isinstance(raw, dict):
3699+
return
3700+
3701+
# Check null in type array (Draft 2020-12 / OpenAPI 3.1+)
3702+
type_value = raw.get("type")
3703+
if isinstance(type_value, list) and "null" in type_value and not self.schema_features.null_in_type_array:
3704+
warn(
3705+
'null in type array (e.g., type: ["string", "null"]) is not supported '
3706+
f"in this schema version. Use nullable: true instead. Schema path: {'/'.join(path)}",
3707+
stacklevel=3,
3708+
)
3709+
3710+
# Check exclusive min/max format (Draft 4 uses boolean, Draft 6+ uses number)
3711+
exclusive_min = raw.get("exclusiveMinimum")
3712+
exclusive_max = raw.get("exclusiveMaximum")
3713+
if self.schema_features.exclusive_as_number:
3714+
# Draft 6+: should be numeric, not boolean
3715+
if isinstance(exclusive_min, bool):
3716+
warn(
3717+
f"exclusiveMinimum as boolean is Draft 4 style, but schema version uses numeric style. "
3718+
f"Schema path: {'/'.join(path)}",
3719+
stacklevel=3,
3720+
)
3721+
if isinstance(exclusive_max, bool):
3722+
warn(
3723+
f"exclusiveMaximum as boolean is Draft 4 style, but schema version uses numeric style. "
3724+
f"Schema path: {'/'.join(path)}",
3725+
stacklevel=3,
3726+
)
3727+
else:
3728+
# Draft 4: should be boolean, not numeric
3729+
if exclusive_min is not None and not isinstance(exclusive_min, bool):
3730+
warn(
3731+
f"exclusiveMinimum as number is Draft 6+ style, but schema version is Draft 4. "
3732+
f"Schema path: {'/'.join(path)}",
3733+
stacklevel=3,
3734+
)
3735+
if exclusive_max is not None and not isinstance(exclusive_max, bool):
3736+
warn(
3737+
f"exclusiveMaximum as number is Draft 6+ style, but schema version is Draft 4. "
3738+
f"Schema path: {'/'.join(path)}",
3739+
stacklevel=3,
3740+
)
3741+
3742+
def _check_array_version_features(
3743+
self,
3744+
obj: JsonSchemaObject,
3745+
path: list[str],
3746+
) -> None:
3747+
"""Check for version-specific array features and warn in Strict mode.
3748+
3749+
Warns when prefixItems is used in versions that don't support it,
3750+
or when items as array (tuple style) is used in Draft 2020-12+.
3751+
"""
3752+
version_mode = getattr(self.config, "schema_version_mode", None)
3753+
if version_mode != VersionMode.Strict:
3754+
return
3755+
3756+
# Check prefixItems usage (Draft 2020-12+ only)
3757+
if obj.prefixItems is not None and not self.schema_features.prefix_items:
3758+
warn(
3759+
f"prefixItems is not supported in this schema version. "
3760+
f"Use items as array for tuple validation. Schema path: {'/'.join(path)}",
3761+
stacklevel=4,
3762+
)
3763+
3764+
# Check items as array usage (deprecated in Draft 2020-12)
3765+
if isinstance(obj.items, list) and self.schema_features.prefix_items:
3766+
warn(
3767+
f"items as array (tuple validation) is deprecated in Draft 2020-12. "
3768+
f"Use prefixItems instead. Schema path: {'/'.join(path)}",
3769+
stacklevel=4,
3770+
)
3771+
36683772
def _handle_python_import(
36693773
self,
36703774
name: str,

src/datamodel_code_generator/parser/openapi.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
load_data,
2828
snooper_to_methods,
2929
)
30-
from datamodel_code_generator.enums import OpenAPIVersion
30+
from datamodel_code_generator.enums import OpenAPIVersion, VersionMode
3131
from datamodel_code_generator.parser.base import get_special_path
3232
from datamodel_code_generator.parser.jsonschema import (
3333
JsonSchemaObject,
@@ -248,8 +248,6 @@ def get_data_type(self, obj: JsonSchemaObject) -> DataType:
248248
- OpenAPI 3.0: nullable: true is valid, convert to type array when strict_nullable
249249
- OpenAPI 3.1: nullable is deprecated, use type: ["string", "null"] instead
250250
"""
251-
from datamodel_code_generator.enums import VersionMode # noqa: PLC0415
252-
253251
if obj.nullable:
254252
if self.schema_features.nullable_keyword:
255253
# OpenAPI 3.0: nullable: true is the standard way

src/datamodel_code_generator/parser/schema_version.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class JsonSchemaFeatures:
3030
boolean_schemas: Draft 6+ allows boolean values as schemas.
3131
id_field: The field name for schema ID ("id" for Draft 4, "$id" for Draft 6+).
3232
definitions_key: The key for definitions ("definitions" or "$defs").
33+
exclusive_as_number: Draft 6+ uses numeric exclusiveMin/Max (Draft 4 uses boolean).
3334
"""
3435

3536
null_in_type_array: bool
@@ -38,6 +39,7 @@ class JsonSchemaFeatures:
3839
boolean_schemas: bool
3940
id_field: str
4041
definitions_key: str
42+
exclusive_as_number: bool
4143

4244
@classmethod
4345
def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
@@ -51,6 +53,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
5153
boolean_schemas=False,
5254
id_field="id",
5355
definitions_key="definitions",
56+
exclusive_as_number=False,
5457
)
5558
case JsonSchemaVersion.Draft6 | JsonSchemaVersion.Draft7:
5659
return cls(
@@ -60,6 +63,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
6063
boolean_schemas=True,
6164
id_field="$id",
6265
definitions_key="definitions",
66+
exclusive_as_number=True,
6367
)
6468
case JsonSchemaVersion.Draft201909:
6569
return cls(
@@ -69,6 +73,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
6973
boolean_schemas=True,
7074
id_field="$id",
7175
definitions_key="$defs",
76+
exclusive_as_number=True,
7277
)
7378
case _:
7479
return cls(
@@ -78,6 +83,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
7883
boolean_schemas=True,
7984
id_field="$id",
8085
definitions_key="$defs",
86+
exclusive_as_number=True,
8187
)
8288

8389

@@ -100,13 +106,16 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures:
100106
"""Create OpenAPISchemaFeatures from an OpenAPI version."""
101107
match version:
102108
case OpenAPIVersion.V30:
109+
# OpenAPI 3.0 schema dialect inherits JSON Schema Draft 4 semantics
110+
# where exclusiveMinimum/Maximum are boolean values
103111
return cls(
104112
null_in_type_array=False,
105113
defs_not_definitions=False,
106114
prefix_items=False,
107115
boolean_schemas=False,
108116
id_field="$id",
109117
definitions_key="definitions",
118+
exclusive_as_number=False,
110119
nullable_keyword=True,
111120
discriminator_support=True,
112121
)
@@ -118,6 +127,7 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures:
118127
boolean_schemas=True,
119128
id_field="$id",
120129
definitions_key="$defs",
130+
exclusive_as_number=True,
121131
nullable_keyword=False,
122132
discriminator_support=True,
123133
)
@@ -158,6 +168,10 @@ def detect_jsonschema_version(data: dict[str, Any]) -> JsonSchemaVersion:
158168
if pattern in schema_url:
159169
return version
160170

171+
# Heuristic detection based on keywords
172+
# $defs was introduced in Draft 2019-09, but Draft 2020-12 also uses it.
173+
# Since 2020-12 is a superset of 2019-09, default to 2020-12 when $defs is present
174+
# to avoid false warnings in Strict mode for features valid in both versions.
161175
if "$defs" in data:
162176
return JsonSchemaVersion.Draft202012
163177
if "definitions" in data:

0 commit comments

Comments
 (0)