Skip to content

Commit 25d8685

Browse files
authored
feat: add support for reserved names and improve error messaging for missing anchors (#2568)
1 parent af3c45e commit 25d8685

7 files changed

Lines changed: 117 additions & 12 deletions

File tree

src/datamodel_code_generator/reference.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from packaging import version
3535
from pydantic import BaseModel, Field
3636

37+
from datamodel_code_generator import Error
3738
from datamodel_code_generator.util import PYDANTIC_V2, ConfigDict, model_validator
3839

3940
if TYPE_CHECKING:
@@ -270,9 +271,10 @@ def get_valid_name( # noqa: PLR0912
270271
else:
271272
new_name = name
272273
while (
273-
not (new_name.isidentifier() or not self._validate_field_name(new_name))
274+
not new_name.isidentifier()
274275
or iskeyword(new_name)
275276
or (excludes and new_name in excludes)
277+
or not self._validate_field_name(new_name)
276278
):
277279
new_name = f"{name}{count}" if upper_camel else f"{name}_{count}"
278280
count += 1
@@ -460,8 +462,6 @@ def base_url_context(self, base_url: str) -> Generator[None, None, None]:
460462
@property
461463
def current_root(self) -> Sequence[str]:
462464
"""Return the current root path components."""
463-
if len(self._current_root) > 1:
464-
return self._current_root
465465
return self._current_root
466466

467467
def set_current_root(self, current_root: Sequence[str]) -> None:
@@ -504,22 +504,29 @@ def resolve_ref(self, path: Sequence[str] | str) -> str: # noqa: PLR0911, PLR09
504504
return f"{'/'.join(self.current_root)}#"
505505
if self.current_base_path and not self.base_url and joined_path[0] != "#" and not is_url(joined_path):
506506
# resolve local file path
507-
file_path, *object_part = joined_path.split("#", 1)
507+
file_path, fragment = joined_path.split("#", 1) if "#" in joined_path else (joined_path, "")
508508
resolved_file_path = Path(self.current_base_path, file_path).resolve()
509509
joined_path = get_relative_path(self._base_path, resolved_file_path).as_posix()
510-
if object_part:
511-
joined_path += f"#{object_part[0]}"
510+
if fragment:
511+
joined_path += f"#{fragment}"
512512
if ID_PATTERN.match(joined_path):
513-
ref: str = self.ids["/".join(self.current_root)][joined_path]
513+
id_scope = "/".join(self.current_root)
514+
scoped_ids = self.ids[id_scope]
515+
ref: str | None = scoped_ids.get(joined_path)
516+
if ref is None:
517+
msg = (
518+
f"Unresolved $id reference '{joined_path}' in scope '{id_scope or '<root>'}'. "
519+
f"Known $id values: {', '.join(sorted(scoped_ids)) or '<none>'}"
520+
)
521+
raise Error(msg)
514522
else:
515523
if "#" not in joined_path:
516524
joined_path += "#"
517525
elif joined_path[0] == "#":
518526
joined_path = f"{'/'.join(self.current_root)}{joined_path}"
519527

520-
delimiter = joined_path.index("#")
521-
file_path = "".join(joined_path[:delimiter])
522-
ref = f"{''.join(joined_path[:delimiter])}#{''.join(joined_path[delimiter + 1 :])}"
528+
file_path, fragment = joined_path.split("#", 1)
529+
ref = f"{file_path}#{fragment}"
523530
if self.root_id_base_path and not (is_url(joined_path) or Path(self._base_path, file_path).is_file()):
524531
ref = f"{self.root_id_base_path}/{ref}"
525532

@@ -544,8 +551,11 @@ def resolve_ref(self, path: Sequence[str] | str) -> str: # noqa: PLR0911, PLR09
544551
root_id_url.netloc,
545552
): # pragma: no cover
546553
target_url_path = Path(target_url.path)
547-
relative_target_base = get_relative_path(Path(root_id_url.path).parent, target_url_path.parent)
548-
target_path = self.current_base_path / relative_target_base / target_url_path.name
554+
target_path = (
555+
self.current_base_path
556+
/ get_relative_path(Path(root_id_url.path).parent, target_url_path.parent)
557+
/ target_url_path.name
558+
)
549559
if target_path.exists():
550560
return f"{target_path.resolve().relative_to(self._base_path)}#{path_part}"
551561

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# generated by datamodel-codegen:
2+
# filename: reserved_property.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import BaseModel, Field
8+
9+
10+
class ReservedNames(BaseModel):
11+
json_: str = Field(..., alias='json')
12+
schema_: str = Field(..., alias='schema')
13+
dict_: int = Field(..., alias='dict')
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# generated by datamodel-codegen:
2+
# filename: with_anchor.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import BaseModel
8+
9+
10+
class Address(BaseModel):
11+
street: str
12+
13+
14+
class Model(BaseModel):
15+
billing_address: Address
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"title": "BrokenAnchor",
3+
"type": "object",
4+
"$ref": "#address"
5+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"title": "ReservedNames",
3+
"type": "object",
4+
"properties": {
5+
"json": {"type": "string"},
6+
"schema": {"type": "string"},
7+
"dict": {"type": "integer"}
8+
},
9+
"required": ["json", "schema", "dict"]
10+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"$id": "http://example.com/root.json",
3+
"type": "object",
4+
"definitions": {
5+
"Address": {
6+
"$id": "#address",
7+
"type": "object",
8+
"properties": {
9+
"street": { "type": "string" }
10+
},
11+
"required": ["street"]
12+
}
13+
},
14+
"properties": {
15+
"billing_address": { "$ref": "#address" }
16+
},
17+
"required": ["billing_address"]
18+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,40 @@ def test_main_invalid_model_name(output_file: Path) -> None:
374374
)
375375

376376

377+
def test_main_jsonschema_reserved_field_names(output_file: Path) -> None:
378+
"""Test reserved names are safely suffixed and aliased."""
379+
run_main_and_assert(
380+
input_path=JSON_SCHEMA_DATA_PATH / "reserved_property.json",
381+
output_path=output_file,
382+
input_file_type="jsonschema",
383+
assert_func=assert_file_content,
384+
expected_file="reserved_property.py",
385+
)
386+
387+
388+
def test_main_jsonschema_with_local_anchor(output_file: Path) -> None:
389+
"""Test $id anchor lookup resolves without error and reuses definitions."""
390+
run_main_and_assert(
391+
input_path=JSON_SCHEMA_DATA_PATH / "with_anchor.json",
392+
output_path=output_file,
393+
input_file_type="jsonschema",
394+
assert_func=assert_file_content,
395+
expected_file="with_anchor.py",
396+
)
397+
398+
399+
def test_main_jsonschema_missing_anchor_reports_error(capsys: pytest.CaptureFixture[str], output_file: Path) -> None:
400+
"""Test missing $id anchor produces a clear error instead of KeyError trace."""
401+
run_main_and_assert(
402+
input_path=JSON_SCHEMA_DATA_PATH / "missing_anchor.json",
403+
output_path=output_file,
404+
input_file_type="jsonschema",
405+
expected_exit=Exit.ERROR,
406+
capsys=capsys,
407+
expected_stderr_contains="Unresolved $id reference '#address'",
408+
)
409+
410+
377411
def test_main_root_id_jsonschema_with_local_file(mocker: MockerFixture, output_file: Path) -> None:
378412
"""Test root ID JSON Schema with local file reference."""
379413
root_id_response = mocker.Mock()

0 commit comments

Comments
 (0)