Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ae7ba7a
Add fix and tests
kevin-paulson-mindbridge-ai Apr 10, 2026
dff13a4
Remove unused noqa
kevin-paulson-mindbridge-ai Apr 10, 2026
b4e675c
Add one more noqa
kevin-paulson-mindbridge-ai Apr 10, 2026
7eaf6ab
Add assertions to test
kevin-paulson-mindbridge-ai Apr 10, 2026
7786cf9
add some generation test for schemas with cyclic references in order …
kevin-paulson-mindbridge-ai Apr 11, 2026
8d3ee39
Add missing expected_file
kevin-paulson-mindbridge-ai Apr 11, 2026
bb037b0
Update the `test_main_cyclic_` tests so that before the change they a…
kevin-paulson-mindbridge-ai Apr 13, 2026
db5f26a
tmp add
kevin-paulson-mindbridge-ai Apr 13, 2026
6b51fbe
Merge remote-tracking branch 'origin/main' into slow_schema_generatio…
kevin-paulson-mindbridge-ai Apr 16, 2026
207dda3
Revert all changes
kevin-paulson-mindbridge-ai Apr 16, 2026
6cc7834
Add test that takes much more than 30 seconds (probably will never fi…
kevin-paulson-mindbridge-ai Apr 16, 2026
f9da08d
Add test timeout
kevin-paulson-mindbridge-ai Apr 16, 2026
670f35d
Code format mostly
kevin-paulson-mindbridge-ai Apr 16, 2026
7deb47d
whitespace changes to help view the code better
kevin-paulson-mindbridge-ai Apr 16, 2026
c380a72
Produce smaler openapi example (still fails)
kevin-paulson-mindbridge-ai Apr 16, 2026
582a495
Resolve test failure
kevin-paulson-mindbridge-ai Apr 16, 2026
45f0208
Merge branch 'main' into slow_schema_generation_fix
kevin-paulson-mindbridge-ai Apr 17, 2026
b3a3e3e
handle None model case in discriminator mapping validation
kevin-paulson-mindbridge-ai Apr 17, 2026
064c620
Rename and describe test
kevin-paulson-mindbridge-ai Apr 17, 2026
d10ed4a
No disable timestamp like the other tests
kevin-paulson-mindbridge-ai Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/datamodel_code_generator/parser/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ def add_model_path_to_list(
return paths


def sort_data_models( # noqa: PLR0912, PLR0915
def sort_data_models( # noqa: PLR0912, PLR0914, PLR0915
unsorted_data_models: list[DataModel],
sorted_data_models: SortedDataModels | None = None,
require_update_action_models: list[str] | None = None,
Expand Down Expand Up @@ -534,6 +534,7 @@ def sort_data_models( # noqa: PLR0912, PLR0915
pass

# sort on base_class dependency
seen_orders: set[tuple[str, ...]] = set()
while True:
ordered_models: list[tuple[int, DataModel]] = []
# Build lookup dict for O(1) index access instead of O(n) list.index()
Expand Down Expand Up @@ -565,6 +566,11 @@ def sort_data_models( # noqa: PLR0912, PLR0915
sorted_unresolved_models = [m[1] for m in sorted(ordered_models, key=operator.itemgetter(0))]
if sorted_unresolved_models == unresolved_references:
break
new_order = tuple(m.path for m in sorted_unresolved_models)
if new_order in seen_orders:
unresolved_references = sorted_unresolved_models
break
seen_orders.add(new_order)
unresolved_references = sorted_unresolved_models

# circular reference
Expand Down Expand Up @@ -1624,7 +1630,9 @@ def get_discriminator_field_value(

if len(discriminator_values) == 0:
for base_class in discriminator_model.base_classes:
check_paths(base_class.reference, mapping) # ty: ignore
if not base_class.reference:
continue
check_paths(base_class.reference, mapping)

if not discriminator_values:
discriminator_values = [discriminator_model.path.split("/")[-1]]
Expand Down
44 changes: 44 additions & 0 deletions tests/data/expected/main/graphql/cyclic_mutual_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# generated by datamodel-codegen:
# filename: cyclic-mutual-types.graphql
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, Field
from typing_extensions import TypeAliasType

Boolean = TypeAliasType("Boolean", bool)
"""
The `Boolean` scalar type represents `true` or `false`.
"""


ID = TypeAliasType("ID", str)
"""
The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID.
"""


String = TypeAliasType("String", str)
"""
The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.
"""


class A(BaseModel):
id: ID
link: B | None = None
name: String | None = None
typename__: Literal['A'] | None = Field('A', alias='__typename')


class B(BaseModel):
id: ID
link: A | None = None
name: String | None = None
typename__: Literal['B'] | None = Field('B', alias='__typename')


A.model_rebuild()
24 changes: 24 additions & 0 deletions tests/data/expected/main/jsonschema/cyclic_mutual_defs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# generated by datamodel-codegen:
# filename: cyclic_mutual_defs.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel, Field, RootModel


class A(BaseModel):
name: str | None = None
b: B | None = None


class B(BaseModel):
name: str | None = None
a: A | None = None


class Root(RootModel[A]):
root: A = Field(..., title='Root')


A.model_rebuild()
20 changes: 20 additions & 0 deletions tests/data/expected/main/openapi/cyclic_component_refs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# generated by datamodel-codegen:
# filename: cyclic_component_refs.yaml
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel


class Team(BaseModel):
name: str | None = None
lead: Person | None = None


class Person(BaseModel):
name: str | None = None
team: Team | None = None


Team.model_rebuild()
15 changes: 15 additions & 0 deletions tests/data/graphql/cyclic-mutual-types.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
type Query {
entry: A
}

type A {
id: ID!
name: String
link: B
}

type B {
id: ID!
name: String
link: A
}
23 changes: 23 additions & 0 deletions tests/data/jsonschema/cyclic_mutual_defs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Root",
"$ref": "#/definitions/A",
"definitions": {
"A": {
"title": "A",
"type": "object",
"properties": {
"name": { "type": "string" },
"b": { "$ref": "#/definitions/B" }
}
},
"B": {
"title": "B",
"type": "object",
"properties": {
"name": { "type": "string" },
"a": { "$ref": "#/definitions/A" }
}
}
}
}
21 changes: 21 additions & 0 deletions tests/data/openapi/cyclic_component_refs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
openapi: 3.0.0
info:
title: Cyclic component $ref
version: "1.0"
paths: {}
components:
schemas:
Team:
type: object
properties:
name:
type: string
lead:
$ref: "#/components/schemas/Person"
Person:
type: object
properties:
name:
type: string
team:
$ref: "#/components/schemas/Team"
10 changes: 10 additions & 0 deletions tests/main/graphql/test_main_graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -917,3 +917,13 @@ def test_main_graphql_no_typename(output_file: Path) -> None:
expected_file="no_typename.py",
extra_args=["--graphql-no-typename"],
)


def test_main_cyclic_mutual_types(output_file: Path) -> None:
"""Mutual object-type cycle (A <-> B) in GraphQL."""
run_main_and_assert(
input_path=GRAPHQL_DATA_PATH / "cyclic-mutual-types.graphql",
output_path=output_file,
input_file_type="graphql",
assert_func=assert_file_content,
)
10 changes: 10 additions & 0 deletions tests/main/jsonschema/test_main_jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,16 @@ def test_main_circular_reference(output_file: Path) -> None:
)


def test_main_cyclic_mutual_defs(output_file: Path) -> None:
"""Minimal mutual cycle between two JSON Schema definitions (A <-> B)."""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "cyclic_mutual_defs.json",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
)


def test_main_invalid_enum_name(output_file: Path) -> None:
"""Test invalid enum name handling."""
run_main_and_assert(
Expand Down
10 changes: 10 additions & 0 deletions tests/main/openapi/test_main_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -4751,6 +4751,16 @@ def test_main_openapi_circular_imports_acyclic(output_dir: Path) -> None:
)


def test_main_cyclic_component_refs(output_file: Path) -> None:
"""Mutual $ref cycle in components/schemas (single-file OpenAPI)."""
run_main_and_assert(
input_path=OPEN_API_DATA_PATH / "cyclic_component_refs.yaml",
output_path=output_file,
input_file_type="openapi",
assert_func=assert_file_content,
)


def test_main_openapi_circular_imports_class_conflict(output_dir: Path) -> None:
"""Test that class name conflicts in merged _internal.py are resolved with sequential renaming."""
with freeze_time(TIMESTAMP):
Expand Down
74 changes: 74 additions & 0 deletions tests/parser/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
if TYPE_CHECKING:
from datamodel_code_generator.parser.schema_version import JsonSchemaFeatures

from datamodel_code_generator.imports import Imports
from datamodel_code_generator.model.base import BaseClassDataType
from datamodel_code_generator.model.pydantic_v2 import BaseModel, DataModelField
from datamodel_code_generator.model.type_alias import TypeAlias, TypeAliasTypeBackport, TypeStatement
from datamodel_code_generator.parser.base import (
Expand Down Expand Up @@ -242,6 +244,78 @@ def test_sort_data_models_unresolved_raise_recursion_error() -> None:
sort_data_models(reference, recursion_count=100000)


def test_sort_data_models_circular_base_classes_no_infinite_loop() -> None:
"""Mutual base-class references must not oscillate forever in the dependency sort."""
reference_a = Reference(path="A", original_name="A", name="A")
reference_b = Reference(path="B", original_name="B", name="B")
reference = [
BaseModel(
fields=[],
reference=reference_a,
base_classes=[reference_b],
),
BaseModel(
fields=[],
reference=reference_b,
base_classes=[reference_a],
),
]

_, resolved, require_update_action_models = sort_data_models(reference)

assert set(resolved) == {"A", "B"}
assert sorted(require_update_action_models) == ["A", "B"]


def test_apply_discriminator_type_skips_base_class_without_reference() -> None:
"""Base class slots without a Reference must not be passed to check_paths."""
ref_pet = Reference(path="#/components/schemas/Pet", original_name="Pet", name="Pet")
pet_model = BaseModel(fields=[], reference=ref_pet)
ref_pet.source = pet_model
pet_model.base_classes.append(BaseClassDataType())

ref_other = Reference(path="#/components/schemas/Other", original_name="Other", name="Other")
other_model = BaseModel(fields=[], reference=ref_other)
ref_other.source = other_model

union_inner = DataType(data_types=[DataType(reference=ref_pet), DataType(reference=ref_other)])
ref_root = Reference(path="#/components/schemas/Root", original_name="Root", name="Root")
field = DataModelField(
name="u",
data_type=union_inner,
extras={
"discriminator": {
"propertyName": "petType",
"mapping": {"dog": "#/components/schemas/Other"},
}
},
)
root = BaseModel(fields=[field], reference=ref_root)
ref_root.source = root

parser = C(
data_model_type=BaseModel,
data_model_root_type=BaseModel,
data_model_field_type=DataModelField,
base_class="BaseModel",
source="",
)
union_variant_types = tuple(union_inner.data_types)
assert len(union_variant_types) == 2
assert {dt.reference.path for dt in union_variant_types} == {ref_pet.path, ref_other.path}
assert {id(dt.reference) for dt in union_variant_types} == {id(ref_pet), id(ref_other)}
pet_base_classes = pet_model.base_classes
bare_base_slot = pet_model.base_classes[-1]

parser._Parser__apply_discriminator_type([root], Imports())

assert tuple(union_inner.data_types) == union_variant_types
assert {dt.reference.path for dt in union_inner.data_types} == {ref_pet.path, ref_other.path}
assert {id(dt.reference) for dt in union_inner.data_types} == {id(ref_pet), id(ref_other)}
assert pet_model.base_classes is pet_base_classes
assert pet_model.base_classes[-1] is bare_base_slot


@pytest.mark.parametrize(
("current_module", "reference", "val"),
[
Expand Down
Loading