From 85fac1d7e913dd1db075e486a0f45cc5256f412b Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Sat, 7 Feb 2026 07:45:26 +0000 Subject: [PATCH 1/6] Support $recursiveRef/$dynamicRef in JSON Schema and OpenAPI --- docs/supported_formats.md | 10 +- src/datamodel_code_generator/parser/base.py | 27 +++- .../parser/jsonschema.py | 137 +++++++++++++++++- .../parser/schema_version.py | 16 ++ .../expected/main/jsonschema/dynamic_ref.py | 15 ++ .../main/jsonschema/dynamic_ref_in_defs.py | 19 +++ .../dynamic_ref_in_defs_pydantic_v2.py | 19 +++ .../jsonschema/dynamic_ref_pydantic_v2.py | 15 ++ .../expected/main/jsonschema/recursive_ref.py | 15 ++ .../main/jsonschema/recursive_ref_in_defs.py | 19 +++ .../recursive_ref_in_defs_pydantic_v2.py | 19 +++ .../jsonschema/recursive_ref_no_anchor.py | 15 ++ .../recursive_ref_no_anchor_pydantic_v2.py | 15 ++ .../jsonschema/recursive_ref_pydantic_v2.py | 15 ++ .../openapi/recursive_ref_discriminator.py | 44 ++++++ ...recursive_ref_discriminator_pydantic_v2.py | 44 ++++++ tests/data/jsonschema/dynamic_ref.json | 12 ++ .../data/jsonschema/dynamic_ref_in_defs.json | 17 +++ tests/data/jsonschema/recursive_ref.json | 12 ++ .../jsonschema/recursive_ref_in_defs.json | 17 +++ .../jsonschema/recursive_ref_no_anchor.json | 11 ++ .../openapi/recursive_ref_discriminator.yaml | 45 ++++++ tests/main/jsonschema/test_main_jsonschema.py | 120 +++++++++++++++ tests/main/openapi/test_main_openapi.py | 24 +++ tests/parser/test_graphql.py | 2 + tests/parser/test_schema_version.py | 24 ++- 26 files changed, 716 insertions(+), 12 deletions(-) create mode 100644 tests/data/expected/main/jsonschema/dynamic_ref.py create mode 100644 tests/data/expected/main/jsonschema/dynamic_ref_in_defs.py create mode 100644 tests/data/expected/main/jsonschema/dynamic_ref_in_defs_pydantic_v2.py create mode 100644 tests/data/expected/main/jsonschema/dynamic_ref_pydantic_v2.py create mode 100644 tests/data/expected/main/jsonschema/recursive_ref.py create mode 100644 tests/data/expected/main/jsonschema/recursive_ref_in_defs.py create mode 100644 tests/data/expected/main/jsonschema/recursive_ref_in_defs_pydantic_v2.py create mode 100644 tests/data/expected/main/jsonschema/recursive_ref_no_anchor.py create mode 100644 tests/data/expected/main/jsonschema/recursive_ref_no_anchor_pydantic_v2.py create mode 100644 tests/data/expected/main/jsonschema/recursive_ref_pydantic_v2.py create mode 100644 tests/data/expected/main/openapi/recursive_ref_discriminator.py create mode 100644 tests/data/expected/main/openapi/recursive_ref_discriminator_pydantic_v2.py create mode 100644 tests/data/jsonschema/dynamic_ref.json create mode 100644 tests/data/jsonschema/dynamic_ref_in_defs.json create mode 100644 tests/data/jsonschema/recursive_ref.json create mode 100644 tests/data/jsonschema/recursive_ref_in_defs.json create mode 100644 tests/data/jsonschema/recursive_ref_no_anchor.json create mode 100644 tests/data/openapi/recursive_ref_discriminator.yaml diff --git a/docs/supported_formats.md b/docs/supported_formats.md index ccc8d2c10..f0bf2fa9a 100644 --- a/docs/supported_formats.md +++ b/docs/supported_formats.md @@ -15,8 +15,8 @@ datamodel-code-generator supports multiple versions of JSON Schema and OpenAPI s | Draft 4 | [json-schema.org/draft-04](https://json-schema.org/draft-04/json-schema-core) | `id`, `definitions` | | Draft 6 | [json-schema.org/draft-06](https://json-schema.org/draft-06/json-schema-release-notes) | `$id`, const, boolean schemas | | Draft 7 | [json-schema.org/draft-07](https://json-schema.org/draft-07/json-schema-release-notes) | if/then/else, readOnly/writeOnly | -| 2019-09 | [json-schema.org/draft/2019-09](https://json-schema.org/draft/2019-09/release-notes) | `$defs`, `$anchor` | -| 2020-12 | [json-schema.org/draft/2020-12](https://json-schema.org/draft/2020-12/release-notes) | `prefixItems`, null in type arrays | +| 2019-09 | [json-schema.org/draft/2019-09](https://json-schema.org/draft/2019-09/release-notes) | `$defs`, `$anchor`, `$recursiveRef`/`$recursiveAnchor` | +| 2020-12 | [json-schema.org/draft/2020-12](https://json-schema.org/draft/2020-12/release-notes) | `prefixItems`, null in type arrays, `$dynamicRef`/`$dynamicAnchor` | ### Feature Compatibility Matrix @@ -38,6 +38,9 @@ datamodel-code-generator supports multiple versions of JSON Schema and OpenAPI s | contains | - | Yes | Yes | Yes | Yes | | **Conditional** | | if/then/else | - | - | Yes | Yes | Yes | +| **Recursive/Dynamic References** | +| `$recursiveRef` / `$recursiveAnchor` | - | - | - | Yes | - | +| `$dynamicRef` / `$dynamicAnchor` | - | - | - | - | Yes | | **Metadata** | | readOnly | - | - | Yes | Yes | Yes | | writeOnly | - | - | Yes | Yes | Yes | @@ -127,7 +130,8 @@ datamodel-code-generator detects the OpenAPI version from the `openapi` field: | Feature | Introduced | Status | Notes | |---------|------------|--------|-------| | `$anchor` | 2019-09 | ❌ Not supported | Use `$ref` with `$id` instead | -| `$dynamicRef` / `$dynamicAnchor` | 2020-12 | ❌ Not supported | Dynamic references | +| `$recursiveRef` / `$recursiveAnchor` | 2019-09 | ✅ Supported | Statically resolved to self-reference | +| `$dynamicRef` / `$dynamicAnchor` | 2020-12 | ✅ Supported | Statically resolved to self-reference | | `unevaluatedProperties` | 2019-09 | ❌ Not supported | Use `additionalProperties` instead | | `unevaluatedItems` | 2019-09 | ❌ Not supported | Use `additionalItems` instead | | `contentMediaType` | Draft 7 | ❌ Not supported | Content type hints ignored | diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 4ff277520..9ce722fa4 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -82,7 +82,7 @@ from datamodel_code_generator.parser._graph import stable_toposort from datamodel_code_generator.parser._scc import find_circular_sccs, strongly_connected_components from datamodel_code_generator.reference import ModelResolver, ModelType, Reference -from datamodel_code_generator.types import DataType, DataTypeManager +from datamodel_code_generator.types import ANY, DataType, DataTypeManager from datamodel_code_generator.util import camel_to_snake, model_copy, model_dump if TYPE_CHECKING: @@ -1452,6 +1452,15 @@ def __apply_discriminator_type( # noqa: PLR0912, PLR0914, PLR0915 ) discriminator["propertyName"] = field_name mapping = discriminator.get("mapping", {}) + # Any type cannot be a discriminated union variant (Pydantic v2 rejects it) + has_any_variant = any( + dt.type == ANY or (not dt.reference and not dt.data_types and not dt.literals and not dt.type) + for dt in field.data_type.data_types + ) + if has_any_variant: # pragma: no cover + field.extras.pop("discriminator", None) + field.data_type.discriminator = None + continue for data_type in field.data_type.data_types: if not data_type.reference: # pragma: no cover continue @@ -1971,11 +1980,19 @@ def __collapse_root_models( # noqa: PLR0912, PLR0914, PLR0915 ) discriminator = root_type_field.extras.get("discriminator") if discriminator and isinstance(root_type_field, pydantic_model.DataModelField): - prop_name = ( - discriminator.get("propertyName") if isinstance(discriminator, dict) else discriminator + has_any_variant = any( + dt.type == ANY + or (not dt.reference and not dt.data_types and not dt.literals and not dt.type) + for dt in copied_data_type.data_types ) - if self._is_pydantic_v2_model(): - copied_data_type.discriminator = prop_name + if not has_any_variant: + prop_name = ( + discriminator.get("propertyName") + if isinstance(discriminator, dict) + else discriminator + ) + if self._is_pydantic_v2_model(): + copied_data_type.discriminator = prop_name assert isinstance(data_type.parent, DataType) data_type.parent.data_types.remove(data_type) data_type.parent.data_types.append(copied_data_type) diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 261a61872..4bae2895e 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -258,6 +258,14 @@ def model_rebuild(cls) -> None: "readOnly", "writeOnly", "deprecated", + "$recursiveRef", + "recursiveRef", + "$recursiveAnchor", + "recursiveAnchor", + "$dynamicRef", + "dynamicRef", + "$dynamicAnchor", + "dynamicAnchor", } @model_validator(mode="before") @@ -358,6 +366,10 @@ def validate_null_type(cls, value: Any) -> Any: # noqa: N805 properties: Optional[dict[str, Union[JsonSchemaObject, bool]]] = None # noqa: UP007, UP045 required: list[str] = Field(default_factory=list) ref: Optional[str] = Field(default=None, alias="$ref") # noqa: UP045 + recursiveRef: Optional[str] = Field(default=None, alias="$recursiveRef") # noqa: N815, UP045 + recursiveAnchor: Optional[bool] = Field(default=None, alias="$recursiveAnchor") # noqa: N815, UP045 + dynamicRef: Optional[str] = Field(default=None, alias="$dynamicRef") # noqa: N815, UP045 + dynamicAnchor: Optional[str] = Field(default=None, alias="$dynamicAnchor") # noqa: N815, UP045 nullable: Optional[bool] = None # noqa: UP045 x_enum_varnames: list[str] = Field(default_factory=list, alias="x-enum-varnames") x_enum_names: list[str] = Field(default_factory=list, alias="x-enumNames") @@ -559,6 +571,10 @@ def _get_type( ) | { "$id", "$ref", + "$recursiveRef", + "$recursiveAnchor", + "$dynamicRef", + "$dynamicAnchor", JsonSchemaObject.__extra_key__, } @@ -688,6 +704,8 @@ def __init__( self._root_id: Optional[str] = None # noqa: UP045 self._root_id_base_path: Optional[str] = None # noqa: UP045 self.reserved_refs: defaultdict[tuple[str, ...], set[str]] = defaultdict(set) + self._dynamic_anchor_index: dict[tuple[str, ...], dict[str, str]] = {} + self._recursive_anchor_index: dict[tuple[str, ...], list[str]] = {} self.field_keys: set[str] = { *DEFAULT_FIELD_KEYS, *self.field_extra_keys, @@ -1566,6 +1584,87 @@ def _load_ref_schema_object(self, ref: str) -> JsonSchemaObject: return model_validate(self.SCHEMA_OBJECT_TYPE, target_schema) + def _build_anchor_indexes(self, obj: JsonSchemaObject, path: list[str]) -> None: + """Build $recursiveAnchor and $dynamicAnchor indexes for a schema object.""" + root_key = tuple(self.model_resolver.current_root) + root_len = len(root_key) + if root_len < len(path): + suffix_parts = path[root_len:] + # Strip leading '#' from fragment markers (e.g. '#/$defs' -> '$defs') + first = suffix_parts[0] + if first.startswith("#"): + suffix_parts = [first[1:].lstrip("/"), *suffix_parts[1:]] + ref_path = "#/" + "/".join(suffix_parts) + else: + ref_path = "#" + if obj.recursiveAnchor: + self._recursive_anchor_index.setdefault(root_key, []).append(ref_path) + if obj.dynamicAnchor: + if root_key not in self._dynamic_anchor_index: # pragma: no cover + self._dynamic_anchor_index[root_key] = {} + self._dynamic_anchor_index[root_key].setdefault(obj.dynamicAnchor, ref_path) + + def _resolve_recursive_ref(self, item: JsonSchemaObject, path: list[str]) -> str | None: + """Resolve $recursiveRef to an equivalent $ref. + + Per JSON Schema 2019-09, $recursiveRef only allows "#" as value. + Resolves to the nearest enclosing schema with $recursiveAnchor: true. + For standalone JSON Schema files, this is the root "#". + For OpenAPI, this is the component schema definition path. + """ + if item.recursiveRef != "#": # pragma: no cover + return None + root_key = tuple(self.model_resolver.current_root) + anchors = self._recursive_anchor_index.get(root_key, []) + if not anchors: + return "#" + # Build root-relative path for comparison + root_len = len(root_key) + if root_len < len(path): + suffix_parts = path[root_len:] + first = suffix_parts[0] + if first.startswith("#"): + suffix_parts = [first[1:].lstrip("/"), *suffix_parts[1:]] + current_ref = "#/" + "/".join(suffix_parts) + else: + current_ref = "#" + # Find the best matching anchor: path prefix with longest match + best = anchors[0] + best_len = 0 + for anchor_ref in anchors: + if anchor_ref == "#": + if best_len == 0: + best = "#" + elif ( + len(anchor_ref) > best_len + and current_ref.startswith(anchor_ref) + and (len(current_ref) == len(anchor_ref) or current_ref[len(anchor_ref)] == "/") + ): + best = anchor_ref + best_len = len(anchor_ref) + return best + + def _resolve_dynamic_ref(self, item: JsonSchemaObject) -> str | None: + """Resolve $dynamicRef to an equivalent $ref. + + Per JSON Schema 2020-12: + 1. Resolve the URI like $ref first (fallback behavior) + 2. If target has $dynamicAnchor, override with outermost matching anchor + + In code generation, dynamic scope is resolved statically via index lookup. + """ + ref = item.dynamicRef + if not ref: # pragma: no cover + return None + if ref.startswith("#"): + anchor_name = ref[1:] + root_key = tuple(self.model_resolver.current_root) + anchor_map = self._dynamic_anchor_index.get(root_key, {}) + if anchor_name in anchor_map: + return anchor_map[anchor_name] + return ref # pragma: no cover + return ref # pragma: no cover + def _merge_ref_with_schema(self, obj: JsonSchemaObject) -> JsonSchemaObject: """Merge $ref schema with current schema's additional keywords. @@ -2909,7 +3008,7 @@ def _should_create_type_alias_for_title( # noqa: PLR0911 ) return bool(is_primitive) - def parse_item( # noqa: PLR0911, PLR0912, PLR0914 + def parse_item( # noqa: PLR0911, PLR0912, PLR0914, PLR0915 self, name: str, item: JsonSchemaObject, @@ -2938,6 +3037,16 @@ def parse_item( # noqa: PLR0911, PLR0912, PLR0914 item, root_type_path, ) + # Resolve $recursiveRef to $ref (JSON Schema 2019-09) + if item.recursiveRef and not item.ref: + resolved_ref = self._resolve_recursive_ref(item, path) + if resolved_ref: + return self.get_ref_data_type(resolved_ref) + # Resolve $dynamicRef to $ref (JSON Schema 2020-12) + if item.dynamicRef and not item.ref: + resolved_ref = self._resolve_dynamic_ref(item) + if resolved_ref: + return self.get_ref_data_type(resolved_ref) if item.is_ref_with_nullable_only and item.ref: ref_data_type = self.get_ref_data_type(item.ref) if self.strict_nullable: @@ -3772,6 +3881,8 @@ def parse_raw_obj( self._check_version_specific_features(raw, path) obj = self._validate_schema_object(raw, path) + # Build $recursiveAnchor / $dynamicAnchor indexes for this schema + self._build_anchor_indexes(obj, path) self.parse_obj(name, obj, path) def _check_version_specific_features( # noqa: PLR0912 @@ -4024,7 +4135,7 @@ def parse_json_pointer(self, raw: dict[str, YamlValue], ref: str, path_parts: li self.parse_raw_obj(model_name, models, [*path_parts, f"#/{object_paths[0]}", *object_paths[1:]]) - def _parse_file( + def _parse_file( # noqa: PLR0912, PLR0915 self, raw: dict[str, Any], obj_name: str, @@ -4042,6 +4153,16 @@ def _parse_file( # parse $id before parsing $ref root_obj = self._validate_schema_object(raw, path_parts or ["#"]) self.parse_id(root_obj, path_parts) + # Build $recursiveAnchor index for root object + if root_obj.recursiveAnchor: + root_key = tuple(path_parts) + self._recursive_anchor_index.setdefault(root_key, []).append("#") + # Build $dynamicAnchor index for root object + if root_obj.dynamicAnchor: + root_key = tuple(path_parts) + if root_key not in self._dynamic_anchor_index: + self._dynamic_anchor_index[root_key] = {} + self._dynamic_anchor_index[root_key].setdefault(root_obj.dynamicAnchor, "#") definitions: dict[str, YamlValue] = {} schema_path = "" for schema_path_candidate, split_schema_path in self.schema_paths: @@ -4056,6 +4177,18 @@ def _parse_file( definition_path = [*path_parts, schema_path, key] obj = self._validate_schema_object(model, definition_path) self.parse_id(obj, definition_path) + # Build $recursiveAnchor index for definitions + if obj.recursiveAnchor: + root_key = tuple(path_parts) + ref_path = "#/" + schema_path.lstrip("#/") + "/" + key + self._recursive_anchor_index.setdefault(root_key, []).append(ref_path) + # Build $dynamicAnchor index for definitions + if obj.dynamicAnchor: + root_key = tuple(path_parts) + if root_key not in self._dynamic_anchor_index: + self._dynamic_anchor_index[root_key] = {} + ref_path = "#/" + schema_path.lstrip("#/") + "/" + key + self._dynamic_anchor_index[root_key].setdefault(obj.dynamicAnchor, ref_path) if object_paths: models = get_model_by_path(raw, object_paths) diff --git a/src/datamodel_code_generator/parser/schema_version.py b/src/datamodel_code_generator/parser/schema_version.py index 853ec7a55..58f5d13b0 100644 --- a/src/datamodel_code_generator/parser/schema_version.py +++ b/src/datamodel_code_generator/parser/schema_version.py @@ -42,6 +42,8 @@ class JsonSchemaFeatures: definitions_key: str exclusive_as_number: bool read_only_write_only: bool + recursive_ref: bool + dynamic_ref: bool @classmethod def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: @@ -57,6 +59,8 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: definitions_key="definitions", exclusive_as_number=False, read_only_write_only=False, + recursive_ref=False, + dynamic_ref=False, ) case JsonSchemaVersion.Draft6: return cls( @@ -68,6 +72,8 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: definitions_key="definitions", exclusive_as_number=True, read_only_write_only=False, + recursive_ref=False, + dynamic_ref=False, ) case JsonSchemaVersion.Draft7: return cls( @@ -79,6 +85,8 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: definitions_key="definitions", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=False, + dynamic_ref=False, ) case JsonSchemaVersion.Draft201909: return cls( @@ -90,6 +98,8 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: definitions_key="$defs", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=True, + dynamic_ref=False, ) case _: return cls( @@ -101,6 +111,8 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures: definitions_key="$defs", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=True, + dynamic_ref=True, ) @@ -132,6 +144,8 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures: definitions_key="definitions", exclusive_as_number=False, read_only_write_only=True, + recursive_ref=False, + dynamic_ref=False, nullable_keyword=True, discriminator_support=True, ) @@ -145,6 +159,8 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures: definitions_key="$defs", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=True, + dynamic_ref=True, nullable_keyword=False, discriminator_support=True, ) diff --git a/tests/data/expected/main/jsonschema/dynamic_ref.py b/tests/data/expected/main/jsonschema/dynamic_ref.py new file mode 100644 index 000000000..46edd81e2 --- /dev/null +++ b/tests/data/expected/main/jsonschema/dynamic_ref.py @@ -0,0 +1,15 @@ +# generated by datamodel-codegen: +# filename: dynamic_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class Model(BaseModel): + name: str | None = None + children: list[Model] | None = None + + +Model.update_forward_refs() diff --git a/tests/data/expected/main/jsonschema/dynamic_ref_in_defs.py b/tests/data/expected/main/jsonschema/dynamic_ref_in_defs.py new file mode 100644 index 000000000..b4916946f --- /dev/null +++ b/tests/data/expected/main/jsonschema/dynamic_ref_in_defs.py @@ -0,0 +1,19 @@ +# generated by datamodel-codegen: +# filename: dynamic_ref_in_defs.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class TreeNode(BaseModel): + value: str | None = None + children: list[TreeNode] | None = None + + +class Model(BaseModel): + __root__: TreeNode + + +TreeNode.update_forward_refs() diff --git a/tests/data/expected/main/jsonschema/dynamic_ref_in_defs_pydantic_v2.py b/tests/data/expected/main/jsonschema/dynamic_ref_in_defs_pydantic_v2.py new file mode 100644 index 000000000..f8ba9cb3f --- /dev/null +++ b/tests/data/expected/main/jsonschema/dynamic_ref_in_defs_pydantic_v2.py @@ -0,0 +1,19 @@ +# generated by datamodel-codegen: +# filename: dynamic_ref_in_defs.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, RootModel + + +class TreeNode(BaseModel): + value: str | None = None + children: list[TreeNode] | None = None + + +class Model(RootModel[TreeNode]): + root: TreeNode + + +TreeNode.model_rebuild() diff --git a/tests/data/expected/main/jsonschema/dynamic_ref_pydantic_v2.py b/tests/data/expected/main/jsonschema/dynamic_ref_pydantic_v2.py new file mode 100644 index 000000000..84dfb3f58 --- /dev/null +++ b/tests/data/expected/main/jsonschema/dynamic_ref_pydantic_v2.py @@ -0,0 +1,15 @@ +# generated by datamodel-codegen: +# filename: dynamic_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class Model(BaseModel): + name: str | None = None + children: list[Model] | None = None + + +Model.model_rebuild() diff --git a/tests/data/expected/main/jsonschema/recursive_ref.py b/tests/data/expected/main/jsonschema/recursive_ref.py new file mode 100644 index 000000000..be641a428 --- /dev/null +++ b/tests/data/expected/main/jsonschema/recursive_ref.py @@ -0,0 +1,15 @@ +# generated by datamodel-codegen: +# filename: recursive_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class Model(BaseModel): + name: str | None = None + children: list[Model] | None = None + + +Model.update_forward_refs() diff --git a/tests/data/expected/main/jsonschema/recursive_ref_in_defs.py b/tests/data/expected/main/jsonschema/recursive_ref_in_defs.py new file mode 100644 index 000000000..dbb4160fb --- /dev/null +++ b/tests/data/expected/main/jsonschema/recursive_ref_in_defs.py @@ -0,0 +1,19 @@ +# generated by datamodel-codegen: +# filename: recursive_ref_in_defs.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class TreeNode(BaseModel): + value: str | None = None + children: list[TreeNode] | None = None + + +class Model(BaseModel): + __root__: TreeNode + + +TreeNode.update_forward_refs() diff --git a/tests/data/expected/main/jsonschema/recursive_ref_in_defs_pydantic_v2.py b/tests/data/expected/main/jsonschema/recursive_ref_in_defs_pydantic_v2.py new file mode 100644 index 000000000..e145f8b13 --- /dev/null +++ b/tests/data/expected/main/jsonschema/recursive_ref_in_defs_pydantic_v2.py @@ -0,0 +1,19 @@ +# generated by datamodel-codegen: +# filename: recursive_ref_in_defs.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, RootModel + + +class TreeNode(BaseModel): + value: str | None = None + children: list[TreeNode] | None = None + + +class Model(RootModel[TreeNode]): + root: TreeNode + + +TreeNode.model_rebuild() diff --git a/tests/data/expected/main/jsonschema/recursive_ref_no_anchor.py b/tests/data/expected/main/jsonschema/recursive_ref_no_anchor.py new file mode 100644 index 000000000..e586de66e --- /dev/null +++ b/tests/data/expected/main/jsonschema/recursive_ref_no_anchor.py @@ -0,0 +1,15 @@ +# generated by datamodel-codegen: +# filename: recursive_ref_no_anchor.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class Model(BaseModel): + name: str | None = None + children: list[Model] | None = None + + +Model.update_forward_refs() diff --git a/tests/data/expected/main/jsonschema/recursive_ref_no_anchor_pydantic_v2.py b/tests/data/expected/main/jsonschema/recursive_ref_no_anchor_pydantic_v2.py new file mode 100644 index 000000000..0ac79e7bc --- /dev/null +++ b/tests/data/expected/main/jsonschema/recursive_ref_no_anchor_pydantic_v2.py @@ -0,0 +1,15 @@ +# generated by datamodel-codegen: +# filename: recursive_ref_no_anchor.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class Model(BaseModel): + name: str | None = None + children: list[Model] | None = None + + +Model.model_rebuild() diff --git a/tests/data/expected/main/jsonschema/recursive_ref_pydantic_v2.py b/tests/data/expected/main/jsonschema/recursive_ref_pydantic_v2.py new file mode 100644 index 000000000..6d2fc1f1e --- /dev/null +++ b/tests/data/expected/main/jsonschema/recursive_ref_pydantic_v2.py @@ -0,0 +1,15 @@ +# generated by datamodel-codegen: +# filename: recursive_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class Model(BaseModel): + name: str | None = None + children: list[Model] | None = None + + +Model.model_rebuild() diff --git a/tests/data/expected/main/openapi/recursive_ref_discriminator.py b/tests/data/expected/main/openapi/recursive_ref_discriminator.py new file mode 100644 index 000000000..356c6dec4 --- /dev/null +++ b/tests/data/expected/main/openapi/recursive_ref_discriminator.py @@ -0,0 +1,44 @@ +# generated by datamodel-codegen: +# filename: recursive_ref_discriminator.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, Extra, Field + + +class Type(Enum): + eq = 'eq' + ne = 'ne' + + +class ComparisonFilter(BaseModel): + class Config: + extra = Extra.forbid + + type: Literal['ComparisonFilter'] + key: str + value: str + + +class Type1(Enum): + and_ = 'and' + or_ = 'or' + + +class Filters(BaseModel): + __root__: ComparisonFilter | CompoundFilter = Field(..., discriminator='type') + + +class CompoundFilter(BaseModel): + class Config: + extra = Extra.forbid + + type: Literal['CompoundFilter'] + filters: list[Filters] + + +Filters.update_forward_refs() diff --git a/tests/data/expected/main/openapi/recursive_ref_discriminator_pydantic_v2.py b/tests/data/expected/main/openapi/recursive_ref_discriminator_pydantic_v2.py new file mode 100644 index 000000000..70e90a031 --- /dev/null +++ b/tests/data/expected/main/openapi/recursive_ref_discriminator_pydantic_v2.py @@ -0,0 +1,44 @@ +# generated by datamodel-codegen: +# filename: recursive_ref_discriminator.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, RootModel + + +class Type(Enum): + eq = 'eq' + ne = 'ne' + + +class ComparisonFilter(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + type: Literal['ComparisonFilter'] + key: str + value: str + + +class Type1(Enum): + and_ = 'and' + or_ = 'or' + + +class CompoundFilter(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + type: Literal['CompoundFilter'] + filters: list[Filters] + + +class Filters(RootModel[ComparisonFilter | CompoundFilter]): + root: ComparisonFilter | CompoundFilter = Field(..., discriminator='type') + + +CompoundFilter.model_rebuild() diff --git a/tests/data/jsonschema/dynamic_ref.json b/tests/data/jsonschema/dynamic_ref.json new file mode 100644 index 000000000..fdf53ab92 --- /dev/null +++ b/tests/data/jsonschema/dynamic_ref.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "name": { "type": "string" }, + "children": { + "type": "array", + "items": { "$dynamicRef": "#node" } + } + } +} diff --git a/tests/data/jsonschema/dynamic_ref_in_defs.json b/tests/data/jsonschema/dynamic_ref_in_defs.json new file mode 100644 index 000000000..ab940b4ac --- /dev/null +++ b/tests/data/jsonschema/dynamic_ref_in_defs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "TreeNode": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": { "$dynamicRef": "#node" } + } + } + } + }, + "$ref": "#/$defs/TreeNode" +} diff --git a/tests/data/jsonschema/recursive_ref.json b/tests/data/jsonschema/recursive_ref.json new file mode 100644 index 000000000..4ba8d148a --- /dev/null +++ b/tests/data/jsonschema/recursive_ref.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$recursiveAnchor": true, + "type": "object", + "properties": { + "name": { "type": "string" }, + "children": { + "type": "array", + "items": { "$recursiveRef": "#" } + } + } +} diff --git a/tests/data/jsonschema/recursive_ref_in_defs.json b/tests/data/jsonschema/recursive_ref_in_defs.json new file mode 100644 index 000000000..9aae1278d --- /dev/null +++ b/tests/data/jsonschema/recursive_ref_in_defs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$defs": { + "TreeNode": { + "$recursiveAnchor": true, + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": { "$recursiveRef": "#" } + } + } + } + }, + "$ref": "#/$defs/TreeNode" +} diff --git a/tests/data/jsonschema/recursive_ref_no_anchor.json b/tests/data/jsonschema/recursive_ref_no_anchor.json new file mode 100644 index 000000000..6edc82828 --- /dev/null +++ b/tests/data/jsonschema/recursive_ref_no_anchor.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "type": "object", + "properties": { + "name": { "type": "string" }, + "children": { + "type": "array", + "items": { "$recursiveRef": "#" } + } + } +} diff --git a/tests/data/openapi/recursive_ref_discriminator.yaml b/tests/data/openapi/recursive_ref_discriminator.yaml new file mode 100644 index 000000000..245348fd9 --- /dev/null +++ b/tests/data/openapi/recursive_ref_discriminator.yaml @@ -0,0 +1,45 @@ +openapi: "3.0.3" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + ComparisonFilter: + type: object + additionalProperties: false + properties: + type: + type: string + enum: + - eq + - ne + key: + type: string + value: + type: string + required: + - type + - key + - value + CompoundFilter: + $recursiveAnchor: true + type: object + additionalProperties: false + properties: + type: + type: string + enum: + - and + - or + filters: + type: array + items: + oneOf: + - $ref: "#/components/schemas/ComparisonFilter" + - $recursiveRef: "#" + discriminator: + propertyName: type + required: + - type + - filters diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 4e37be716..2fbaf16c8 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -8078,3 +8078,123 @@ def test_main_allof_mro(output_file: Path) -> None: "--use-schema-description", ], ) + + +def test_main_jsonschema_recursive_ref(output_file: Path) -> None: + """Test JSON Schema 2019-09 $recursiveRef with $recursiveAnchor.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "recursive_ref.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="recursive_ref.py", + ) + + +@PYDANTIC_V2_SKIP +def test_main_jsonschema_recursive_ref_pydantic_v2(output_file: Path) -> None: + """Test JSON Schema 2019-09 $recursiveRef with Pydantic v2.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "recursive_ref.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="recursive_ref_pydantic_v2.py", + extra_args=["--output-model-type", "pydantic_v2.BaseModel"], + ) + + +def test_main_jsonschema_dynamic_ref(output_file: Path) -> None: + """Test JSON Schema 2020-12 $dynamicRef with $dynamicAnchor.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "dynamic_ref.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="dynamic_ref.py", + ) + + +@PYDANTIC_V2_SKIP +def test_main_jsonschema_dynamic_ref_pydantic_v2(output_file: Path) -> None: + """Test JSON Schema 2020-12 $dynamicRef with Pydantic v2.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "dynamic_ref.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="dynamic_ref_pydantic_v2.py", + extra_args=["--output-model-type", "pydantic_v2.BaseModel"], + ) + + +def test_main_jsonschema_recursive_ref_no_anchor(output_file: Path) -> None: + """Test JSON Schema 2019-09 $recursiveRef without $recursiveAnchor.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "recursive_ref_no_anchor.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="recursive_ref_no_anchor.py", + ) + + +@PYDANTIC_V2_SKIP +def test_main_jsonschema_recursive_ref_no_anchor_pydantic_v2(output_file: Path) -> None: + """Test JSON Schema 2019-09 $recursiveRef without $recursiveAnchor for Pydantic v2.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "recursive_ref_no_anchor.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="recursive_ref_no_anchor_pydantic_v2.py", + extra_args=["--output-model-type", "pydantic_v2.BaseModel"], + ) + + +def test_main_jsonschema_recursive_ref_in_defs(output_file: Path) -> None: + """Test JSON Schema 2019-09 $recursiveRef with anchor in $defs.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "recursive_ref_in_defs.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="recursive_ref_in_defs.py", + ) + + +@PYDANTIC_V2_SKIP +def test_main_jsonschema_recursive_ref_in_defs_pydantic_v2(output_file: Path) -> None: + """Test JSON Schema 2019-09 $recursiveRef with anchor in $defs for Pydantic v2.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "recursive_ref_in_defs.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="recursive_ref_in_defs_pydantic_v2.py", + extra_args=["--output-model-type", "pydantic_v2.BaseModel"], + ) + + +def test_main_jsonschema_dynamic_ref_in_defs(output_file: Path) -> None: + """Test JSON Schema 2020-12 $dynamicRef with anchor in $defs.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "dynamic_ref_in_defs.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="dynamic_ref_in_defs.py", + ) + + +@PYDANTIC_V2_SKIP +def test_main_jsonschema_dynamic_ref_in_defs_pydantic_v2(output_file: Path) -> None: + """Test JSON Schema 2020-12 $dynamicRef with anchor in $defs for Pydantic v2.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "dynamic_ref_in_defs.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="dynamic_ref_in_defs_pydantic_v2.py", + extra_args=["--output-model-type", "pydantic_v2.BaseModel"], + ) diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index 47cf8e3d2..f30b678ca 100644 --- a/tests/main/openapi/test_main_openapi.py +++ b/tests/main/openapi/test_main_openapi.py @@ -4939,3 +4939,27 @@ def test_main_openapi_deprecated_field(output_file: Path) -> None: expected_file="deprecated_field.py", extra_args=["--output-model-type", "pydantic_v2.BaseModel"], ) + + +def test_main_openapi_recursive_ref_discriminator(output_file: Path) -> None: + """Test OpenAPI generation with $recursiveRef and discriminator.""" + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "recursive_ref_discriminator.yaml", + output_path=output_file, + input_file_type="openapi", + assert_func=assert_file_content, + expected_file="recursive_ref_discriminator.py", + ) + + +@SKIP_PYDANTIC_V1 +def test_main_openapi_recursive_ref_discriminator_pydantic_v2(output_file: Path) -> None: + """Test OpenAPI generation with $recursiveRef and discriminator for Pydantic v2.""" + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "recursive_ref_discriminator.yaml", + output_path=output_file, + input_file_type="openapi", + assert_func=assert_file_content, + expected_file="recursive_ref_discriminator_pydantic_v2.py", + extra_args=["--output-model-type", "pydantic_v2.BaseModel"], + ) diff --git a/tests/parser/test_graphql.py b/tests/parser/test_graphql.py index 79413f213..9908a43fa 100644 --- a/tests/parser/test_graphql.py +++ b/tests/parser/test_graphql.py @@ -170,5 +170,7 @@ def test_graphql_schema_features() -> None: definitions_key="$defs", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=True, + dynamic_ref=True, ) ) diff --git a/tests/parser/test_schema_version.py b/tests/parser/test_schema_version.py index eb99402b5..e9aca9a7e 100644 --- a/tests/parser/test_schema_version.py +++ b/tests/parser/test_schema_version.py @@ -116,6 +116,8 @@ def test_jsonschema_features_draft4() -> None: definitions_key="definitions", exclusive_as_number=False, read_only_write_only=False, + recursive_ref=False, + dynamic_ref=False, ) ) @@ -132,6 +134,8 @@ def test_jsonschema_features_draft6() -> None: definitions_key="definitions", exclusive_as_number=True, read_only_write_only=False, + recursive_ref=False, + dynamic_ref=False, ) ) @@ -148,6 +152,8 @@ def test_jsonschema_features_draft7() -> None: definitions_key="definitions", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=False, + dynamic_ref=False, ) ) @@ -164,6 +170,8 @@ def test_jsonschema_features_2019_09() -> None: definitions_key="$defs", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=True, + dynamic_ref=False, ) ) @@ -180,6 +188,8 @@ def test_jsonschema_features_2020_12() -> None: definitions_key="$defs", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=True, + dynamic_ref=True, ) ) @@ -196,6 +206,8 @@ def test_jsonschema_features_auto() -> None: definitions_key="$defs", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=True, + dynamic_ref=True, ) ) @@ -219,6 +231,8 @@ def test_openapi_features_v30() -> None: definitions_key="definitions", exclusive_as_number=False, read_only_write_only=True, + recursive_ref=False, + dynamic_ref=False, nullable_keyword=True, discriminator_support=True, ) @@ -237,6 +251,8 @@ def test_openapi_features_v31() -> None: definitions_key="$defs", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=True, + dynamic_ref=True, nullable_keyword=False, discriminator_support=True, ) @@ -255,6 +271,8 @@ def test_openapi_features_auto() -> None: definitions_key="$defs", exclusive_as_number=True, read_only_write_only=True, + recursive_ref=True, + dynamic_ref=True, nullable_keyword=False, discriminator_support=True, ) @@ -968,7 +986,8 @@ def test_cli_schema_version_jsonschema_parametrized(schema_version: str) -> None class Model(BaseModel): - s: str""" + s: str\ +""" ) @@ -1041,7 +1060,8 @@ def test_cli_schema_version_mode_parametrized(version_mode: VersionMode) -> None class Model(BaseModel): - s: str""" + s: str\ +""" ) From 63dba9907db7d57069ae4f54cd4e6a4fe7c17dd8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Feb 2026 07:56:13 +0000 Subject: [PATCH 2/6] docs: update llms.txt files Generated by GitHub Actions --- docs/llms-full.txt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index ce47d8cfb..09f06722d 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -24183,8 +24183,8 @@ datamodel-code-generator supports multiple versions of JSON Schema and OpenAPI s | Draft 4 | [json-schema.org/draft-04](https://json-schema.org/draft-04/json-schema-core) | `id`, `definitions` | | Draft 6 | [json-schema.org/draft-06](https://json-schema.org/draft-06/json-schema-release-notes) | `$id`, const, boolean schemas | | Draft 7 | [json-schema.org/draft-07](https://json-schema.org/draft-07/json-schema-release-notes) | if/then/else, readOnly/writeOnly | -| 2019-09 | [json-schema.org/draft/2019-09](https://json-schema.org/draft/2019-09/release-notes) | `$defs`, `$anchor` | -| 2020-12 | [json-schema.org/draft/2020-12](https://json-schema.org/draft/2020-12/release-notes) | `prefixItems`, null in type arrays | +| 2019-09 | [json-schema.org/draft/2019-09](https://json-schema.org/draft/2019-09/release-notes) | `$defs`, `$anchor`, `$recursiveRef`/`$recursiveAnchor` | +| 2020-12 | [json-schema.org/draft/2020-12](https://json-schema.org/draft/2020-12/release-notes) | `prefixItems`, null in type arrays, `$dynamicRef`/`$dynamicAnchor` | ### Feature Compatibility Matrix @@ -24206,6 +24206,9 @@ datamodel-code-generator supports multiple versions of JSON Schema and OpenAPI s | contains | - | Yes | Yes | Yes | Yes | | **Conditional** | | if/then/else | - | - | Yes | Yes | Yes | +| **Recursive/Dynamic References** | | | | | | +| `$recursiveRef` / `$recursiveAnchor` | - | - | - | Yes | - | +| `$dynamicRef` / `$dynamicAnchor` | - | - | - | - | Yes | | **Metadata** | | readOnly | - | - | Yes | Yes | Yes | | writeOnly | - | - | Yes | Yes | Yes | @@ -24287,7 +24290,8 @@ The following features are tracked in the codebase with their implementation sta | `unevaluatedItems` | 2019-09 | ❌ Not Supported | Additional items not evaluated by subschemas | | `dependentRequired` | 2019-09 | ❌ Not Supported | Conditional property requirements | | `dependentSchemas` | 2019-09 | ❌ Not Supported | Conditional schema application based on property presence | -| `$dynamicRef/$dynamicAnchor` | 2020-12 | ❌ Not Supported | Dynamic reference resolution across schemas | +| `$recursiveRef/$recursiveAnchor` | 2019-09 | ✅ Supported | Recursive reference resolution via anchors | +| `$dynamicRef/$dynamicAnchor` | 2020-12 | ✅ Supported | Dynamic reference resolution across schemas | #### OpenAPI-Specific Features @@ -24341,7 +24345,8 @@ The following features are tracked in the codebase with their implementation sta | Feature | Introduced | Status | Notes | |---------|------------|--------|-------| | `$anchor` | 2019-09 | ❌ Not supported | Use `$ref` with `$id` instead | -| `$dynamicRef` / `$dynamicAnchor` | 2020-12 | ❌ Not supported | Dynamic references | +| `$recursiveRef` / `$recursiveAnchor` | 2019-09 | ✅ Supported | Statically resolved to self-reference | +| `$dynamicRef` / `$dynamicAnchor` | 2020-12 | ✅ Supported | Statically resolved to self-reference | | `unevaluatedProperties` | 2019-09 | ❌ Not supported | Use `additionalProperties` instead | | `unevaluatedItems` | 2019-09 | ❌ Not supported | Use `additionalItems` instead | | `contentMediaType` | Draft 7 | ❌ Not supported | Content type hints ignored | From 83a574e525dc5527da1dabfe2a6489c5f2950f9e Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Sat, 7 Feb 2026 08:02:39 +0000 Subject: [PATCH 3/6] Remove unrelated diff in test_schema_version.py --- tests/parser/test_schema_version.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/parser/test_schema_version.py b/tests/parser/test_schema_version.py index b63a927c9..1ba3c943f 100644 --- a/tests/parser/test_schema_version.py +++ b/tests/parser/test_schema_version.py @@ -992,8 +992,7 @@ def test_cli_schema_version_jsonschema_parametrized(schema_version: str) -> None class Model(BaseModel): - s: str\ -""" + s: str""" ) @@ -1066,8 +1065,7 @@ def test_cli_schema_version_mode_parametrized(version_mode: VersionMode) -> None class Model(BaseModel): - s: str\ -""" + s: str""" ) From 8d0ab61662f047418c237e831c0b4e78f21a319c Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Sat, 7 Feb 2026 08:20:21 +0000 Subject: [PATCH 4/6] Fix partial branch coverage --- src/datamodel_code_generator/parser/base.py | 2 +- .../parser/jsonschema.py | 22 +++++-------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index a3ee080cb..5b882b5c8 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -2009,7 +2009,7 @@ def __collapse_root_models( # noqa: PLR0912, PLR0914, PLR0915 or (not dt.reference and not dt.data_types and not dt.literals and not dt.type) for dt in copied_data_type.data_types ) - if not has_any_variant: + if not has_any_variant: # pragma: no branch prop_name = ( discriminator.get("propertyName") if isinstance(discriminator, dict) diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 1ce76b730..de25babf2 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -1613,13 +1613,11 @@ def _resolve_recursive_ref(self, item: JsonSchemaObject, path: list[str]) -> str else: current_ref = "#" # pragma: no cover # Find the best matching anchor: path prefix with longest match + # best defaults to "#" (root anchor fallback) best = "#" best_len = 0 for anchor_ref in anchors: - if anchor_ref == "#": - if best_len == 0: - best = "#" - elif ( + if anchor_ref != "#" and ( len(anchor_ref) > best_len and current_ref.startswith(anchor_ref) and (len(current_ref) == len(anchor_ref) or current_ref[len(anchor_ref)] == "/") @@ -3026,14 +3024,10 @@ def parse_item( # noqa: PLR0911, PLR0912, PLR0914, PLR0915 ) # Resolve $recursiveRef to $ref (JSON Schema 2019-09) if item.recursiveRef and not item.ref: - resolved_ref = self._resolve_recursive_ref(item, path) - if resolved_ref: - return self.get_ref_data_type(resolved_ref) + return self.get_ref_data_type(self._resolve_recursive_ref(item, path) or "#") # Resolve $dynamicRef to $ref (JSON Schema 2020-12) if item.dynamicRef and not item.ref: - resolved_ref = self._resolve_dynamic_ref(item) - if resolved_ref: - return self.get_ref_data_type(resolved_ref) + return self.get_ref_data_type(self._resolve_dynamic_ref(item) or item.dynamicRef) if item.is_ref_with_nullable_only and item.ref: ref_data_type = self.get_ref_data_type(item.ref) if self.strict_nullable: @@ -4164,9 +4158,7 @@ def _parse_file( # noqa: PLR0912, PLR0915 # Build $dynamicAnchor index for root object if root_obj.dynamicAnchor: root_key = tuple(path_parts) - if root_key not in self._dynamic_anchor_index: - self._dynamic_anchor_index[root_key] = {} - self._dynamic_anchor_index[root_key].setdefault(root_obj.dynamicAnchor, "#") + self._dynamic_anchor_index.setdefault(root_key, {}).setdefault(root_obj.dynamicAnchor, "#") definitions: dict[str, YamlValue] = {} schema_path = "" for schema_path_candidate, split_schema_path in self.schema_paths: @@ -4189,10 +4181,8 @@ def _parse_file( # noqa: PLR0912, PLR0915 # Build $dynamicAnchor index for definitions if obj.dynamicAnchor: root_key = tuple(path_parts) - if root_key not in self._dynamic_anchor_index: - self._dynamic_anchor_index[root_key] = {} ref_path = "#/" + schema_path.lstrip("#/") + "/" + key - self._dynamic_anchor_index[root_key].setdefault(obj.dynamicAnchor, ref_path) + self._dynamic_anchor_index.setdefault(root_key, {}).setdefault(obj.dynamicAnchor, ref_path) if object_paths: models = get_model_by_path(raw, object_paths) From f0b1e834fabb049b5388065d974b10856c7c02d9 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Feb 2026 08:53:41 +0000 Subject: [PATCH 5/6] Address review feedback and merge main --- docs/supported_formats.md | 2 -- src/datamodel_code_generator/parser/jsonschema.py | 14 +++----------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/docs/supported_formats.md b/docs/supported_formats.md index 09c0473ec..10c1a9858 100644 --- a/docs/supported_formats.md +++ b/docs/supported_formats.md @@ -177,8 +177,6 @@ The following features are tracked in the codebase with their implementation sta | Feature | Introduced | Status | Notes | |---------|------------|--------|-------| | `$anchor` | 2019-09 | ❌ Not supported | Use `$ref` with `$id` instead | -| `$recursiveRef` / `$recursiveAnchor` | 2019-09 | ✅ Supported | Statically resolved to self-reference | -| `$dynamicRef` / `$dynamicAnchor` | 2020-12 | ✅ Supported | Statically resolved to self-reference | | `unevaluatedProperties` | 2019-09 | ❌ Not supported | Use `additionalProperties` instead | | `unevaluatedItems` | 2019-09 | ❌ Not supported | Use `additionalItems` instead | | `contentMediaType` | Draft 7 | ❌ Not supported | Content type hints ignored | diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index de25babf2..29700b8ec 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -1576,7 +1576,6 @@ def _build_anchor_indexes(self, obj: JsonSchemaObject, path: list[str]) -> None: root_len = len(root_key) if root_len < len(path): suffix_parts = path[root_len:] - # Strip leading '#' from fragment markers (e.g. '#/$defs' -> '$defs') first = suffix_parts[0] if first.startswith("#"): suffix_parts = [first[1:].lstrip("/"), *suffix_parts[1:]] @@ -1584,7 +1583,9 @@ def _build_anchor_indexes(self, obj: JsonSchemaObject, path: list[str]) -> None: else: ref_path = "#" if obj.recursiveAnchor: - self._recursive_anchor_index.setdefault(root_key, []).append(ref_path) + anchors = self._recursive_anchor_index.setdefault(root_key, []) + if ref_path not in anchors: + anchors.append(ref_path) if obj.dynamicAnchor: self._dynamic_anchor_index.setdefault(root_key, {}).setdefault(obj.dynamicAnchor, ref_path) @@ -1602,7 +1603,6 @@ def _resolve_recursive_ref(self, item: JsonSchemaObject, path: list[str]) -> str anchors = self._recursive_anchor_index.get(root_key, []) if not anchors: return "#" - # Build root-relative path for comparison root_len = len(root_key) if root_len < len(path): suffix_parts = path[root_len:] @@ -1612,8 +1612,6 @@ def _resolve_recursive_ref(self, item: JsonSchemaObject, path: list[str]) -> str current_ref = "#/" + "/".join(suffix_parts) else: current_ref = "#" # pragma: no cover - # Find the best matching anchor: path prefix with longest match - # best defaults to "#" (root anchor fallback) best = "#" best_len = 0 for anchor_ref in anchors: @@ -3022,10 +3020,8 @@ def parse_item( # noqa: PLR0911, PLR0912, PLR0914, PLR0915 item, root_type_path, ) - # Resolve $recursiveRef to $ref (JSON Schema 2019-09) if item.recursiveRef and not item.ref: return self.get_ref_data_type(self._resolve_recursive_ref(item, path) or "#") - # Resolve $dynamicRef to $ref (JSON Schema 2020-12) if item.dynamicRef and not item.ref: return self.get_ref_data_type(self._resolve_dynamic_ref(item) or item.dynamicRef) if item.is_ref_with_nullable_only and item.ref: @@ -4151,11 +4147,9 @@ def _parse_file( # noqa: PLR0912, PLR0915 # parse $id before parsing $ref root_obj = self._validate_schema_object(raw, path_parts or ["#"]) self.parse_id(root_obj, path_parts) - # Build $recursiveAnchor index for root object if root_obj.recursiveAnchor: root_key = tuple(path_parts) self._recursive_anchor_index.setdefault(root_key, []).append("#") - # Build $dynamicAnchor index for root object if root_obj.dynamicAnchor: root_key = tuple(path_parts) self._dynamic_anchor_index.setdefault(root_key, {}).setdefault(root_obj.dynamicAnchor, "#") @@ -4173,12 +4167,10 @@ def _parse_file( # noqa: PLR0912, PLR0915 definition_path = [*path_parts, schema_path, key] obj = self._validate_schema_object(model, definition_path) self.parse_id(obj, definition_path) - # Build $recursiveAnchor index for definitions if obj.recursiveAnchor: root_key = tuple(path_parts) ref_path = "#/" + schema_path.lstrip("#/") + "/" + key self._recursive_anchor_index.setdefault(root_key, []).append(ref_path) - # Build $dynamicAnchor index for definitions if obj.dynamicAnchor: root_key = tuple(path_parts) ref_path = "#/" + schema_path.lstrip("#/") + "/" + key From 7b96f301143cb7ee28184df48f20b5e2badf5ee4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Feb 2026 08:54:14 +0000 Subject: [PATCH 6/6] docs: update llms.txt files Generated by GitHub Actions --- docs/llms-full.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 09f06722d..137f0c833 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -24345,8 +24345,6 @@ The following features are tracked in the codebase with their implementation sta | Feature | Introduced | Status | Notes | |---------|------------|--------|-------| | `$anchor` | 2019-09 | ❌ Not supported | Use `$ref` with `$id` instead | -| `$recursiveRef` / `$recursiveAnchor` | 2019-09 | ✅ Supported | Statically resolved to self-reference | -| `$dynamicRef` / `$dynamicAnchor` | 2020-12 | ✅ Supported | Statically resolved to self-reference | | `unevaluatedProperties` | 2019-09 | ❌ Not supported | Use `additionalProperties` instead | | `unevaluatedItems` | 2019-09 | ❌ Not supported | Use `additionalItems` instead | | `contentMediaType` | Draft 7 | ❌ Not supported | Content type hints ignored |