Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
39 changes: 27 additions & 12 deletions src/datamodel_code_generator/imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,27 +88,39 @@ def append(self, imports: Import | Iterable[Import] | None) -> None:
if import_.alias:
self.alias[import_.from_][import_.import_] = import_.alias

def remove(self, imports: Import | Iterable[Import]) -> None:
def remove(self, imports: Import | Iterable[Import]) -> None: # noqa: PLR0912
"""Remove one or more imports from the collection."""
if isinstance(imports, Import): # pragma: no cover
imports = [imports]
for import_ in imports:
if "." in import_.import_: # pragma: no cover
self.counter[None, import_.import_] -= 1
if self.counter[None, import_.import_] == 0: # pragma: no cover
self[None].remove(import_.import_)
if not self[None]:
del self[None]
key = (None, import_.import_)
if self.counter.get(key, 0) <= 0:
continue
self.counter[key] -= 1
if self.counter[key] == 0: # pragma: no cover
del self.counter[key]
if None in self and import_.import_ in self[None]:
self[None].remove(import_.import_)
if not self[None]:
del self[None]
else:
self.counter[import_.from_, import_.import_] -= 1 # pragma: no cover
if self.counter[import_.from_, import_.import_] == 0: # pragma: no cover
self[import_.from_].remove(import_.import_)
if not self[import_.from_]:
del self[import_.from_]
if import_.alias: # pragma: no cover
key = (import_.from_, import_.import_)
if self.counter.get(key, 0) <= 0:
continue
self.counter[key] -= 1 # pragma: no cover
if self.counter[key] == 0: # pragma: no cover
del self.counter[key]
if import_.from_ in self and import_.import_ in self[import_.from_]:
self[import_.from_].remove(import_.import_)
if not self[import_.from_]:
del self[import_.from_]
if import_.alias and import_.from_ in self.alias and import_.import_ in self.alias[import_.from_]:
del self.alias[import_.from_][import_.import_]
if not self.alias[import_.from_]:
del self.alias[import_.from_]
if import_.reference_path and import_.reference_path in self.reference_paths:
del self.reference_paths[import_.reference_path]

def remove_referenced_imports(self, reference_path: str) -> None:
"""Remove imports associated with a reference path."""
Expand All @@ -126,6 +138,9 @@ def extract_future(self) -> Imports:
future.counter[key] = self.counter.pop(key)
if future_key in self.alias:
future.alias[future_key] = self.alias.pop(future_key)
for ref_path, import_ in list(self.reference_paths.items()):
if import_.from_ == future_key:
future.reference_paths[ref_path] = self.reference_paths.pop(ref_path)
return future

def add_export(self, name: str) -> None:
Expand Down
6 changes: 6 additions & 0 deletions src/datamodel_code_generator/model/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,9 +328,12 @@ def copy_deep(self) -> Self:
"""Create a deep copy of this field to avoid mutating the original."""
copied = self.copy()
copied.parent = None
copied.extras = deepcopy(self.extras)
copied.data_type = self.data_type.copy()
if self.data_type.data_types:
copied.data_type.data_types = [dt.copy() for dt in self.data_type.data_types]
if self.data_type.dict_key:
copied.data_type.dict_key = self.data_type.dict_key.copy()
return copied

def replace_data_type(self, new_data_type: DataType, *, clear_old_parent: bool = True) -> None:
Expand Down Expand Up @@ -549,6 +552,9 @@ def create_reuse_model(self, base_ref: Reference) -> Self:
path=self.reference.path + "/reuse",
),
custom_template_dir=self._custom_template_dir,
custom_base_class=self.custom_base_class,
keyword_only=self.keyword_only,
treat_dot_as_module=self._treat_dot_as_module,
)

def replace_children_in_models(self, models: list[DataModel], new_ref: Reference) -> None:
Expand Down
21 changes: 19 additions & 2 deletions src/datamodel_code_generator/model/dataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,14 @@
from datamodel_code_generator.model.pydantic.base_model import Constraints # noqa: TC001 # needed for pydantic
from datamodel_code_generator.model.types import DataTypeManager as _DataTypeManager
from datamodel_code_generator.model.types import type_map_factory
from datamodel_code_generator.reference import Reference

Check notice

Code scanning / CodeQL

Cyclic import Note

Import of module
datamodel_code_generator.reference
begins an import cycle.

Copilot Autofix

AI 5 months ago

In general, cycles caused by type-like utilities can be fixed by making the dependency one-way at runtime: either move shared types into a more central module, or ensure one side only imports the other for type checking (using TYPE_CHECKING and from __future__ import annotations) or local, function‑scope imports.

The single best minimal fix here, without changing existing functionality, is to stop importing Reference at module level and instead import it only for typing. Because this file already uses from __future__ import annotations, we can safely annotate with "Reference" as a string and import Reference only inside the TYPE_CHECKING block. That way, the runtime import graph no longer includes datamodel_code_generator.reference from dataclass.py, breaking the cycle, while static type checkers still see the correct type. This does not change runtime behavior: Reference is only needed for type annotations and as a constructor call, and with postponed evaluation, using the forward‑referenced annotation as a string is sufficient.

Concretely in src/datamodel_code_generator/model/dataclass.py:

  • Remove the top-level from datamodel_code_generator.reference import Reference import.
  • Inside the existing if TYPE_CHECKING: block, add from datamodel_code_generator.reference import Reference.
  • Update any type annotations that directly reference Reference to use a forward reference string "Reference" instead of the bare name, so they remain valid even if the name is not bound at runtime (although with from __future__ import annotations, this is largely for clarity and robustness).
  • No other logic, parameters, or behavior needs to change.
Suggested changeset 1
src/datamodel_code_generator/model/dataclass.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/datamodel_code_generator/model/dataclass.py b/src/datamodel_code_generator/model/dataclass.py
--- a/src/datamodel_code_generator/model/dataclass.py
+++ b/src/datamodel_code_generator/model/dataclass.py
@@ -21,13 +21,13 @@
 from datamodel_code_generator.model.pydantic.base_model import Constraints  # noqa: TC001 # needed for pydantic
 from datamodel_code_generator.model.types import DataTypeManager as _DataTypeManager
 from datamodel_code_generator.model.types import type_map_factory
-from datamodel_code_generator.reference import Reference
 from datamodel_code_generator.types import DataType, StrictTypes, Types, chain_as_tuple
 
 if TYPE_CHECKING:
     from collections import defaultdict
     from collections.abc import Sequence
     from pathlib import Path
+    from datamodel_code_generator.reference import Reference
 
 
 def has_field_assignment(field: DataModelFieldBase) -> bool:
EOF
@@ -21,13 +21,13 @@
from datamodel_code_generator.model.pydantic.base_model import Constraints # noqa: TC001 # needed for pydantic
from datamodel_code_generator.model.types import DataTypeManager as _DataTypeManager
from datamodel_code_generator.model.types import type_map_factory
from datamodel_code_generator.reference import Reference
from datamodel_code_generator.types import DataType, StrictTypes, Types, chain_as_tuple

if TYPE_CHECKING:
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
from datamodel_code_generator.reference import Reference


def has_field_assignment(field: DataModelFieldBase) -> bool:
Copilot is powered by AI and may make mistakes. Always verify output.
from datamodel_code_generator.types import DataType, StrictTypes, Types, chain_as_tuple

if TYPE_CHECKING:
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path

from datamodel_code_generator.reference import Reference


def has_field_assignment(field: DataModelFieldBase) -> bool:
"""Check if a dataclass field has a default value or field() assignment."""
Expand Down Expand Up @@ -91,6 +90,24 @@
if keyword_only:
self.dataclass_arguments["kw_only"] = True

def create_reuse_model(self, base_ref: Reference) -> DataClass:
"""Create inherited model with empty fields pointing to base reference."""
return self.__class__(
fields=[],
base_classes=[base_ref],
description=self.description,
reference=Reference(
name=self.name,
path=self.reference.path + "/reuse",
),
custom_template_dir=self._custom_template_dir,
custom_base_class=self.custom_base_class,
keyword_only=self.keyword_only,
frozen=self.frozen,
treat_dot_as_module=self._treat_dot_as_module,
dataclass_arguments=self.dataclass_arguments,
)


class DataModelField(DataModelFieldBase):
"""Field implementation for dataclass models."""
Expand Down
6 changes: 2 additions & 4 deletions src/datamodel_code_generator/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,10 @@ def _remove_none_from_union(type_: str, *, use_union_operator: bool) -> str: #
elif char == "]" and in_constr == 0:
inner_count -= 1
elif char == "(":
if current_part.strip().startswith("constr(") and current_part[-2] != "\\":
# non-escaped opening round bracket found inside constraint string expression
if current_part.strip().startswith("constr(") and (len(current_part) < 2 or current_part[-2] != "\\"): # noqa: PLR2004
in_constr += 1
elif char == ")":
if in_constr > 0 and current_part[-2] != "\\":
# non-escaped closing round bracket found inside constraint string expression
if in_constr > 0 and (len(current_part) < 2 or current_part[-2] != "\\"): # noqa: PLR2004
in_constr -= 1
elif char == separator and inner_count == 0 and in_constr == 0:
part = current_part[:-1].strip()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# generated by datamodel-codegen:
# filename: reuse_scope_tree_dataclass
# timestamp: 2019-07-26T00:00:00+00:00
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# generated by datamodel-codegen:
# filename: reuse_scope_tree_dataclass
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional

from .shared import SharedModel as SharedModel_1


@dataclass(frozen=True)
class SharedModel(SharedModel_1):
pass


@dataclass(frozen=True)
class Model:
data: Optional[SharedModel] = None
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# generated by datamodel-codegen:
# filename: schema_b.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional

from . import shared


@dataclass(frozen=True)
class Model:
info: Optional[shared.SharedModel] = None
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# generated by datamodel-codegen:
# filename: shared.py
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional


@dataclass(frozen=True)
class SharedModel:
id: Optional[int] = None
name: Optional[str] = None
18 changes: 18 additions & 0 deletions tests/main/jsonschema/test_main_jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3923,6 +3923,24 @@ def test_main_jsonschema_reuse_scope_tree_dataclass(output_dir: Path) -> None:
)


def test_main_jsonschema_reuse_scope_tree_dataclass_frozen(output_dir: Path) -> None:
"""Test --reuse-scope=tree with frozen dataclasses preserves frozen in inherited models."""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "reuse_scope_tree_dataclass",
output_path=output_dir,
expected_directory=EXPECTED_JSON_SCHEMA_PATH / "reuse_scope_tree_dataclass_frozen",
input_file_type="jsonschema",
extra_args=[
"--reuse-model",
"--reuse-scope",
"tree",
"--output-model-type",
"dataclasses.dataclass",
"--frozen",
],
)


def test_main_jsonschema_reuse_scope_tree_typeddict(output_dir: Path) -> None:
"""Test --reuse-scope=tree with TypedDict output type (no inheritance, direct reference)."""
run_main_and_assert(
Expand Down
59 changes: 59 additions & 0 deletions tests/test_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,62 @@ def test_extract_future_with_alias() -> None:
assert "annotations as ann" in str(future)
assert "__future__" not in imports
assert "__future__" not in imports.alias


def test_remove_nonexistent_import() -> None:
"""Test that removing non-existent import doesn't crash."""
imports = Imports()
imports.append(Import(from_="typing", import_="Optional"))

imports.remove(Import(from_="typing", import_="List"))

assert str(imports) == "from typing import Optional"


def test_remove_double_removal() -> None:
"""Test that double removal of same import doesn't crash."""
imports = Imports()
imports.append(Import(from_="typing", import_="Optional"))

imports.remove(Import(from_="typing", import_="Optional"))
imports.remove(Import(from_="typing", import_="Optional"))

assert not str(imports)


def test_remove_cleans_up_counter() -> None:
"""Test that remove() properly cleans up counter entries."""
imports = Imports()
imports.append(Import(from_="typing", import_="Optional"))

assert imports.counter.get(("typing", "Optional")) == 1

imports.remove(Import(from_="typing", import_="Optional"))

assert ("typing", "Optional") not in imports.counter


def test_remove_cleans_up_reference_paths() -> None:
"""Test that remove() properly cleans up reference_paths."""
imports = Imports()
imports.append(Import(from_="typing", import_="Optional", reference_path="/test/path"))

assert "/test/path" in imports.reference_paths

imports.remove(Import(from_="typing", import_="Optional", reference_path="/test/path"))

assert "/test/path" not in imports.reference_paths


def test_extract_future_moves_reference_paths() -> None:
"""Test that extract_future() moves reference_paths for __future__ imports."""
imports = Imports()
imports.append(Import(from_="__future__", import_="annotations", reference_path="/future/ref"))
imports.append(Import(from_="typing", import_="Optional", reference_path="/typing/ref"))

future = imports.extract_future()

assert "/future/ref" in future.reference_paths
assert "/future/ref" not in imports.reference_paths
assert "/typing/ref" in imports.reference_paths
assert "/typing/ref" not in future.reference_paths
19 changes: 19 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,22 @@ def test_get_optional_type(input_: str, use_union_operator: bool, expected: str)
def test_remove_none_from_union(type_str: str, use_union_operator: bool, expected: str) -> None:
"""Test _remove_none_from_union function with various type strings."""
assert _remove_none_from_union(type_str, use_union_operator=use_union_operator) == expected


@pytest.mark.parametrize(
("type_str", "use_union_operator", "expected"),
[
("(", False, "("),
(")", False, ")"),
("()", False, "()"),
("a(", False, "a("),
("constr()", False, "constr()"),
("constr(pattern=')')", False, "constr(pattern=')')"),
("Union[constr()]", False, "constr()"),
("a | b", True, "a | b"),
("(a)", True, "(a)"),
],
)
def test_remove_none_from_union_short_strings(type_str: str, use_union_operator: bool, expected: str) -> None:
"""Test _remove_none_from_union with short strings to verify index bounds safety."""
assert _remove_none_from_union(type_str, use_union_operator=use_union_operator) == expected
Loading