Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,10 @@ dev = [
test = [
"inline-snapshot>=0.31.1",
"msgspec>=0.18",
"pytest>=6.1",
"pytest>=8.3.4",
"pytest-cov>=2.12.1",
"pytest-cov>=5",
"pytest-mock>=3.14",
"pytest-timeout>=2.4",
"pytest-xdist>=3.3.1",
"time-machine>=3.1",
"watchfiles>=1.1",
Expand Down
4 changes: 2 additions & 2 deletions src/datamodel_code_generator/model/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def _build_union_type_hint(self) -> str | None:
"""Build Union[] type hint from data_type.data_types if forward reference requires it."""
if not (self._use_union_operator != self.data_type.use_union_operator and self.data_type.is_union):
return None
parts = [dt.type_hint for dt in self.data_type.data_types if dt.type_hint]
parts = dict.fromkeys(dt.type_hint for dt in self.data_type.data_types if dt.type_hint).keys()
if len(parts) > 1:
return f"Union[{', '.join(parts)}]"
return None # pragma: no cover
Expand All @@ -232,7 +232,7 @@ def _build_base_union_type_hint(self) -> str | None: # pragma: no cover
"""Build Union[] base type hint from data_type.data_types if forward reference requires it."""
if not (self._use_union_operator != self.data_type.use_union_operator and self.data_type.is_union):
return None
parts = [dt.base_type_hint for dt in self.data_type.data_types if dt.base_type_hint]
parts = dict.fromkeys(dt.base_type_hint for dt in self.data_type.data_types if dt.base_type_hint).keys()
if len(parts) > 1:
return f"Union[{', '.join(parts)}]"
return None
Expand Down
28 changes: 26 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 All @@ -502,8 +502,10 @@ def sort_data_models( # noqa: PLR0912, PLR0915
"""Sort data models by dependency order for correct forward references."""
if sorted_data_models is None:
sorted_data_models = OrderedDict()

if require_update_action_models is None:
require_update_action_models = []

sorted_model_count: int = len(sorted_data_models)

unresolved_references: list[DataModel] = []
Expand All @@ -521,6 +523,7 @@ def sort_data_models( # noqa: PLR0912, PLR0915
add_model_path_to_list(require_update_action_models, model)
else:
unresolved_references.append(model)

if unresolved_references:
if sorted_model_count != len(sorted_data_models) and recursion_count:
try:
Expand All @@ -534,6 +537,7 @@ def sort_data_models( # noqa: PLR0912, PLR0915
pass

# sort on base_class dependency
seen_orderings: 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 All @@ -552,6 +556,7 @@ def sort_data_models( # noqa: PLR0912, PLR0915
for b in model.base_classes
if b.reference and b.reference.path in path_to_index
]

if indexes:
ordered_models.append((
max(indexes),
Expand All @@ -562,9 +567,19 @@ def sort_data_models( # noqa: PLR0912, PLR0915
-1,
model,
))

sorted_unresolved_models = [m[1] for m in sorted(ordered_models, key=operator.itemgetter(0))]
if sorted_unresolved_models == unresolved_references:
break

sig = tuple(m.path for m in sorted_unresolved_models)
if sig in seen_orderings:
# Base-class dependency order has no fixed point (e.g. cyclic inheritance with
# discriminators). Further iterations only permute the list; use stable order.
unresolved_references.sort(key=lambda m: m.path)
break

seen_orderings.add(sig)
unresolved_references = sorted_unresolved_models

# circular reference
Expand All @@ -578,16 +593,19 @@ def sort_data_models( # noqa: PLR0912, PLR0915
if update_action_parent:
add_model_path_to_list(require_update_action_models, model)
continue

if not unresolved_model - unsorted_data_model_names:
sorted_data_models[model.path] = model
add_model_path_to_list(require_update_action_models, model)
continue

# unresolved
unresolved_classes = ", ".join(
f"[class: {item.path} references: {item.reference_classes}]" for item in unresolved_references
)
msg = f"A Parser can not resolve classes: {unresolved_classes}."
raise Exception(msg) # noqa: TRY002

return unresolved_references, sorted_data_models, require_update_action_models


Expand Down Expand Up @@ -1576,11 +1594,14 @@ def __apply_discriminator_type( # noqa: PLR0912, PLR0914, PLR0915
discriminator_values: list[DiscriminatorValue] = []

def check_paths(
model: pydantic_model_v2.BaseModel | Reference,
model: pydantic_model_v2.BaseModel | Reference | None,
mapping: dict[str, str],
discriminator_values: list[DiscriminatorValue] = discriminator_values,
) -> None:
"""Validate discriminator mapping paths for a model."""
if model is None:
return

for name, path in mapping.items():
if (model.path.split("#/")[-1] != path.split("#/")[-1]) and (
path.startswith("#/") or model.path[:-1] != path.split("/")[-1]
Expand Down Expand Up @@ -1624,6 +1645,9 @@ def get_discriminator_field_value(

if len(discriminator_values) == 0:
for base_class in discriminator_model.base_classes:
if not base_class.reference:
continue

check_paths(base_class.reference, mapping) # ty: ignore

if not discriminator_values:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# generated by datamodel-codegen:
# filename: openapi_discriminated_oneof_allof_cycle.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from typing import Literal, Union

from pydantic import BaseModel, Field, RootModel


class ASchema(BaseModel):
pass


class BSchema(BaseModel):
pass


class A1(BaseModel):
kind: Literal['a']


class B1(BaseModel):
kind: Literal['b']


class A(RootModel[Union["A2", "A3"]]):
root: Union["A2", "A3"]


class A2(A1):
pass


class A3(A1):
pass


class B(RootModel["B2"]):
root: "B2"


class B2(B1):
pass


class X(RootModel[A | B]):
root: A | B = Field(..., discriminator='kind')


A.model_rebuild()
B.model_rebuild()
74 changes: 74 additions & 0 deletions tests/data/openapi/openapi_discriminated_oneof_allof_cycle.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{
"openapi": "3.1.0",
"info": {
"title": "",
"version": ""
},
"paths": {},
"components": {
"schemas": {
"ASchema": {
"type": "object",
"description": "Schema referenced by discriminator mapping."
},
"BSchema": {
"type": "object",
"description": "Schema referenced by discriminator mapping."
},
"X": {
"type": "object",
"discriminator": {
"propertyName": "kind",
"mapping": {
"a": "#/components/schemas/ASchema",
"b": "#/components/schemas/BSchema"
}
},
"oneOf": [
{
"$ref": "#/components/schemas/A"
},
{
"$ref": "#/components/schemas/B"
}
]
},
"A": {
"allOf": [
{
"$ref": "#/components/schemas/X"
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"const": "a"
}
}
}
]
},
"B": {
"allOf": [
{
"$ref": "#/components/schemas/X"
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"const": "b"
}
}
}
]
}
}
}
}
15 changes: 15 additions & 0 deletions tests/main/openapi/test_main_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5316,3 +5316,18 @@ def test_main_reuse_model_with_type_alias(output_file: Path) -> None:
"--use-type-alias",
],
)


@pytest.mark.timeout(30)
def test_main_openapi_discriminated_oneof_allof_cycle(output_file: Path) -> None:
"""Discriminated oneOf with variants that allOf the parent (circular graph).

Covers sort_data_models ordering for cyclic base dependencies and discriminator
handling (mapping + RootModel) on a minimal OpenAPI spec.
"""
run_main_and_assert(
input_path=OPEN_API_DATA_PATH / "openapi_discriminated_oneof_allof_cycle.json",
output_path=output_file,
input_file_type="openapi",
assert_func=assert_file_content,
)
24 changes: 18 additions & 6 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading