Skip to content

Commit 8b2c605

Browse files
Handle nested schema namespaces (#3445)
* Handle nested schema namespaces * style: auto-fix by pre-commit.ci * Handle nested schema namespaces * Normalize current root schema path comparison --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 61e08d4 commit 8b2c605

9 files changed

Lines changed: 281 additions & 7 deletions

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5443,15 +5443,25 @@ def _handle_python_import(
54435443
def _is_named_schema_definition_path(self, path: list[str]) -> bool:
54445444
"""Check if path points to a named schema entry under definitions/$defs."""
54455445
current_root = list(self.model_resolver.current_root)
5446-
expected_path_length = len(current_root) + 2
5447-
if len(path) != expected_path_length:
5446+
if len(path) < len(current_root) + 2:
54485447
return False
54495448

54505449
schema_container_path = path[len(current_root)]
54515450
return path[: len(current_root)] == current_root and any(
54525451
schema_container_path == schema_path for schema_path, _ in self.schema_paths
54535452
)
54545453

5454+
def _is_current_root_schema_path(self, path: list[str]) -> bool:
5455+
current_root = list(self.model_resolver.current_root)
5456+
if path == (current_root or ["#"]):
5457+
return True
5458+
return self.model_resolver.resolve_ref(path) == self.model_resolver.resolve_ref(current_root or "#")
5459+
5460+
def _drop_ref_from_schema(self, obj: JsonSchemaObject) -> JsonSchemaObject:
5461+
return self.SCHEMA_OBJECT_TYPE.model_validate(
5462+
obj.model_dump(exclude={"ref"}, exclude_unset=True, by_alias=True)
5463+
)
5464+
54555465
def parse_obj( # noqa: PLR0912
54565466
self,
54575467
name: str,
@@ -5460,7 +5470,10 @@ def parse_obj( # noqa: PLR0912
54605470
) -> None:
54615471
"""Parse a JsonSchemaObject by dispatching to appropriate parse methods."""
54625472
if obj.has_ref_with_schema_keywords and not obj.is_ref_with_nullable_only:
5463-
obj = self._merge_ref_with_schema(obj)
5473+
if obj.ref == "#" and self._is_current_root_schema_path(path):
5474+
obj = self._drop_ref_from_schema(obj)
5475+
else:
5476+
obj = self._merge_ref_with_schema(obj)
54645477
if obj.ref:
54655478
if self._is_named_schema_definition_path(path):
54665479
self.parse_root_type(name, obj, path)
@@ -5646,6 +5659,78 @@ def parse_json_pointer(self, raw: dict[str, YamlValue], ref: str, path_parts: li
56465659

56475660
self.parse_raw_obj(model_name, models, [*path_parts, f"#/{reference_paths[0]}", *reference_paths[1:]])
56485661

5662+
def _known_schema_object_raw_keys(self) -> set[str]:
5663+
keys = {"definitions", "$defs"}
5664+
for name, field in self.SCHEMA_OBJECT_TYPE.get_fields().items(): # ty: ignore
5665+
keys.add(name)
5666+
if alias := getattr(field, "alias", None):
5667+
keys.add(alias)
5668+
return keys
5669+
5670+
def _has_schema_affecting_keywords(self, raw: dict[str, Any]) -> bool:
5671+
metadata_keys = {
5672+
*self.SCHEMA_OBJECT_TYPE.__metadata_only_fields__,
5673+
"extras",
5674+
self.SCHEMA_OBJECT_TYPE.__extra_key__,
5675+
}
5676+
schema_affecting_keys = {
5677+
*self._known_schema_object_raw_keys(),
5678+
*self.SCHEMA_OBJECT_TYPE.__schema_affecting_extras__,
5679+
} - metadata_keys
5680+
return any(str(key) in schema_affecting_keys for key in raw)
5681+
5682+
def _is_version_definition_namespace_name(self, name: str) -> bool: # noqa: PLR6301
5683+
return re.fullmatch(r"v\d+(?:[._-]\d+)*", name, flags=re.IGNORECASE) is not None
5684+
5685+
def _iter_definition_namespace_entries(
5686+
self,
5687+
raw: dict[str, Any],
5688+
path: list[str],
5689+
*,
5690+
include_direct_children: bool,
5691+
) -> Iterator[tuple[str, YamlValue, list[str]]]:
5692+
for schema_key in ("definitions", "$defs"):
5693+
if isinstance(definitions := raw.get(schema_key), dict):
5694+
yield from self._iter_schema_definition_entries(definitions, [*path, schema_key])
5695+
5696+
if not include_direct_children:
5697+
return
5698+
5699+
known_keys = self._known_schema_object_raw_keys()
5700+
for key, value in raw.items():
5701+
key_str = str(key)
5702+
if key_str in known_keys or key_str.startswith("x-") or not isinstance(value, (dict, bool)):
5703+
continue
5704+
yield from self._iter_schema_definition_entry(key_str, value, [*path, key_str])
5705+
5706+
def _iter_schema_definition_entry(
5707+
self,
5708+
name: str,
5709+
raw: YamlValue,
5710+
path: list[str],
5711+
) -> Iterator[tuple[str, YamlValue, list[str]]]:
5712+
if isinstance(raw, dict) and not self._has_schema_affecting_keywords(raw):
5713+
entries = list(
5714+
self._iter_definition_namespace_entries(
5715+
raw,
5716+
path,
5717+
include_direct_children=self._is_version_definition_namespace_name(name),
5718+
)
5719+
)
5720+
if entries:
5721+
yield from entries
5722+
return
5723+
yield name, raw, path
5724+
5725+
def _iter_schema_definition_entries(
5726+
self,
5727+
definitions: dict[str, YamlValue],
5728+
base_path: list[str],
5729+
) -> Iterator[tuple[str, YamlValue, list[str]]]:
5730+
for key, model in definitions.items():
5731+
name = str(key)
5732+
yield from self._iter_schema_definition_entry(name, model, [*base_path, name])
5733+
56495734
def _parse_file( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
56505735
self,
56515736
raw: dict[str, Any],
@@ -5697,8 +5782,16 @@ def _parse_file( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
56975782
except KeyError: # pragma: no cover
56985783
continue
56995784

5700-
for key, model in definitions.items():
5701-
definition_path = [*path_parts, schema_path, key]
5785+
definition_entries = list(self._iter_schema_definition_entries(definitions, [*path_parts, schema_path]))
5786+
definition_metadata_entries = [
5787+
*((str(key), model, [*path_parts, schema_path, str(key)]) for key, model in definitions.items()),
5788+
*definition_entries,
5789+
]
5790+
seen_definition_metadata_paths: set[tuple[str, ...]] = set()
5791+
for _key, model, definition_path in definition_metadata_entries:
5792+
if (definition_path_key := tuple(definition_path)) in seen_definition_metadata_paths:
5793+
continue
5794+
seen_definition_metadata_paths.add(definition_path_key)
57025795
obj = self._validate_schema_object(model, definition_path)
57035796
self.parse_id(obj, definition_path)
57045797
if obj.recursiveAnchor:
@@ -5714,8 +5807,7 @@ def _parse_file( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
57145807
self.parse_obj(model_name, self._validate_schema_object(models, path), path)
57155808
elif not self.skip_root_model:
57165809
self.parse_obj(obj_name, root_obj, path_parts or ["#"])
5717-
for key, model in definitions.items():
5718-
path = [*path_parts, schema_path, key]
5810+
for key, model, path in definition_entries:
57195811
reference = self.model_resolver.get(path)
57205812
if not reference or not reference.loaded:
57215813
self.parse_raw_obj(key, model, path)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# generated by datamodel-codegen:
2+
# filename: definitions_v2_namespace.json
3+
4+
from __future__ import annotations
5+
6+
from pydantic import BaseModel
7+
8+
9+
class User(BaseModel):
10+
id: int | None = None
11+
12+
13+
class Audit(BaseModel):
14+
event: str | None = None
15+
16+
17+
class Envelope(BaseModel):
18+
user: User | None = None
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# generated by datamodel-codegen:
2+
# filename: nested_defs_namespace_ref.json
3+
4+
from __future__ import annotations
5+
6+
from pydantic import BaseModel
7+
8+
9+
class Pet(BaseModel):
10+
tag: str | None = None
11+
12+
13+
class User(BaseModel):
14+
name: str | None = None
15+
16+
17+
class Envelope(BaseModel):
18+
user: User | None = None
19+
pet: Pet | None = None
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# generated by datamodel-codegen:
2+
# filename: root_ref_self_with_keywords.json
3+
4+
from __future__ import annotations
5+
6+
from pydantic import BaseModel
7+
8+
9+
class Model(BaseModel):
10+
name: str
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"title": "Envelope",
4+
"type": "object",
5+
"properties": {
6+
"user": {
7+
"$ref": "#/definitions/v2/User"
8+
}
9+
},
10+
"definitions": {
11+
"v2": {
12+
"User": {
13+
"type": "object",
14+
"properties": {
15+
"id": {
16+
"type": "integer"
17+
}
18+
}
19+
},
20+
"Audit": {
21+
"type": "object",
22+
"properties": {
23+
"event": {
24+
"type": "string"
25+
}
26+
}
27+
}
28+
}
29+
}
30+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"title": "Envelope",
4+
"type": "object",
5+
"properties": {
6+
"user": {
7+
"$ref": "#/$defs/v2/$defs/User"
8+
},
9+
"pet": {
10+
"$ref": "#/$defs/v2/definitions/Pet"
11+
}
12+
},
13+
"$defs": {
14+
"v2": {
15+
"$defs": {
16+
"User": {
17+
"type": "object",
18+
"properties": {
19+
"name": {
20+
"type": "string"
21+
}
22+
}
23+
}
24+
},
25+
"definitions": {
26+
"Pet": {
27+
"type": "object",
28+
"properties": {
29+
"tag": {
30+
"type": "string"
31+
}
32+
}
33+
}
34+
}
35+
}
36+
}
37+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$ref": "#",
4+
"type": "object",
5+
"required": ["name"],
6+
"properties": {
7+
"name": {
8+
"type": "string"
9+
}
10+
}
11+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2035,6 +2035,42 @@ def test_main_nested_json_pointer(output_file: Path) -> None:
20352035
)
20362036

20372037

2038+
def test_main_jsonschema_definitions_namespace(output_file: Path) -> None:
2039+
"""Test nested namespace-like definitions are parsed without wrapper models."""
2040+
run_main_and_assert(
2041+
input_path=JSON_SCHEMA_DATA_PATH / "definitions_v2_namespace.json",
2042+
output_path=output_file,
2043+
input_file_type="jsonschema",
2044+
assert_func=assert_file_content,
2045+
expected_file="definitions_v2_namespace.py",
2046+
extra_args=["--disable-timestamp"],
2047+
)
2048+
2049+
2050+
def test_main_jsonschema_nested_defs_namespace_ref(output_file: Path) -> None:
2051+
"""Test nested $defs/definitions namespace references skip wrapper models."""
2052+
run_main_and_assert(
2053+
input_path=JSON_SCHEMA_DATA_PATH / "nested_defs_namespace_ref.json",
2054+
output_path=output_file,
2055+
input_file_type="jsonschema",
2056+
assert_func=assert_file_content,
2057+
expected_file="nested_defs_namespace_ref.py",
2058+
extra_args=["--disable-timestamp"],
2059+
)
2060+
2061+
2062+
def test_main_jsonschema_root_ref_self_with_keywords(output_file: Path) -> None:
2063+
"""Test root $ref '#' with local schema keywords parses the local schema."""
2064+
run_main_and_assert(
2065+
input_path=JSON_SCHEMA_DATA_PATH / "root_ref_self_with_keywords.json",
2066+
output_path=output_file,
2067+
input_file_type="jsonschema",
2068+
assert_func=assert_file_content,
2069+
expected_file="root_ref_self_with_keywords.py",
2070+
extra_args=["--disable-timestamp"],
2071+
)
2072+
2073+
20382074
def test_main_jsonschema_multiple_files_json_pointer(output_dir: Path) -> None:
20392075
"""Test JSON pointer with multiple files."""
20402076
run_main_and_assert(

tests/parser/test_jsonschema.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1862,6 +1862,27 @@ def test_jsonschema_parser_edge_case_helpers() -> None:
18621862
assert parser._get_data_type_from_json_value(object()).type_hint == "Any"
18631863

18641864

1865+
@pytest.mark.parametrize(
1866+
("current_root", "path", "expected"),
1867+
[
1868+
([], [], True),
1869+
([], ["#"], True),
1870+
(["schema.json"], ["schema.json"], True),
1871+
(["schema.json"], ["schema.json", "#"], True),
1872+
(["schema.json"], ["schema.json#"], True),
1873+
(["schema.json"], ["other.json#"], False),
1874+
],
1875+
)
1876+
def test_is_current_root_schema_path_normalizes_root_spellings(
1877+
current_root: list[str], path: list[str], *, expected: bool
1878+
) -> None:
1879+
"""Treat equivalent root path spellings as the current schema root."""
1880+
parser = JsonSchemaParser("")
1881+
parser.model_resolver.set_current_root(current_root)
1882+
1883+
assert parser._is_current_root_schema_path(path) is expected
1884+
1885+
18651886
def test_anchor_ref_path_escapes_json_pointer_segments() -> None:
18661887
"""Test anchor ref paths escape JSON Pointer segments."""
18671888
parser = JsonSchemaParser("")

0 commit comments

Comments
 (0)