Skip to content

Commit 72d1df4

Browse files
committed
Add comprehensive version-specific feature checks with exclusive_as_number flag
1 parent c269b9c commit 72d1df4

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]
@@ -2935,6 +2934,9 @@ def parse_array_fields( # noqa: PLR0912
29352934
singular_name: bool = True, # noqa: FBT001, FBT002
29362935
) -> DataModelFieldBase:
29372936
"""Parse array schema into a data model field with list type."""
2937+
# Strict mode: check for version-specific array features
2938+
self._check_array_version_features(obj, path)
2939+
29382940
if self.force_optional_for_required_fields:
29392941
required: bool = False
29402942
nullable: Optional[bool] = None # noqa: UP045
@@ -3645,9 +3647,111 @@ def parse_raw_obj(
36453647
if isinstance(raw, dict) and "x-python-import" in raw:
36463648
self._handle_python_import(name, path)
36473649
return
3650+
3651+
# Strict mode: check for version-specific features before validation
3652+
self._check_version_specific_features(raw, path)
3653+
36483654
obj = self._validate_schema_object(raw, path)
36493655
self.parse_obj(name, obj, path)
36503656

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