Skip to content

Commit fb3a4b6

Browse files
authored
Fix single-file treat-dot-as-module (#2603)
* Fix: Implement handling for treating dots as module in single file * Fix: Add tests for to_hashable function and postprocessing of module results * Fix: Add test for postprocessing of single element without dot in module results * Fix: Update condition for max_passes to improve code clarity
1 parent 7086f52 commit fb3a4b6

7 files changed

Lines changed: 134 additions & 7 deletions

File tree

src/datamodel_code_generator/parser/base.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def get_special_path(keyword: str, path: list[str]) -> list[str]:
9898
})
9999

100100

101-
def to_hashable(item: Any) -> HashableComparable:
101+
def to_hashable(item: Any) -> HashableComparable: # noqa: PLR0911
102102
"""Convert an item to a hashable and comparable representation.
103103
104104
Returns a value that is both hashable and supports comparison operators.
@@ -111,7 +111,11 @@ def to_hashable(item: Any) -> HashableComparable:
111111
tuple,
112112
),
113113
):
114-
return tuple(sorted(to_hashable(i) for i in item))
114+
try:
115+
return tuple(sorted((to_hashable(i) for i in item), key=lambda v: (str(type(v)), v)))
116+
except TypeError:
117+
# Fallback when mixed, non-comparable types are present; preserve original order
118+
return tuple(to_hashable(i) for i in item)
115119
if isinstance(item, dict):
116120
return tuple(
117121
sorted(
@@ -669,7 +673,7 @@ def _get_text_from_url(self, url: str) -> str:
669673

670674
return self.remote_text_cache.get_or_put(
671675
url,
672-
default_factory=lambda url_: get_body( # noqa: ARG005
676+
default_factory=lambda _url: get_body(
673677
url, self.http_headers, self.http_ignore_tls, self.http_query_parameters
674678
),
675679
)
@@ -974,9 +978,9 @@ def check_paths(
974978
alias=alias,
975979
)
976980
)
977-
has_imported_literal = any(import_ == IMPORT_LITERAL for import_ in imports)
978-
if has_imported_literal: # pragma: no cover
979-
imports.append(IMPORT_LITERAL)
981+
has_imported_literal = any(import_ == IMPORT_LITERAL for import_ in imports)
982+
if has_imported_literal: # pragma: no cover
983+
imports.append(IMPORT_LITERAL)
980984

981985
@classmethod
982986
def _create_set_from_list(cls, data_type: DataType) -> DataType | None:
@@ -1406,6 +1410,7 @@ def __sort_models(
14061410
model_class_name_refs[model] = (class_name, refs)
14071411

14081412
changed: bool = True
1413+
max_passes = len(models) * len(models) if models else 0
14091414
while changed:
14101415
changed = False
14111416
resolved = imported.copy()
@@ -1417,6 +1422,10 @@ def __sort_models(
14171422
continue
14181423
models[i], models[i + 1] = models[i + 1], model
14191424
changed = True
1425+
if max_passes: # pragma: no branch
1426+
max_passes -= 1
1427+
if max_passes <= 0: # pragma: no cover
1428+
break
14201429

14211430
def __change_field_name(
14221431
self,
@@ -1490,7 +1499,8 @@ def process(input_tuple: tuple[str, ...]) -> tuple[str, ...]:
14901499
else:
14911500
r.append(item)
14921501

1493-
r = [*r[:-2], f"{r[-2]}.{r[-1]}"]
1502+
if len(r) >= 2: # noqa: PLR2004
1503+
r = [*r[:-2], f"{r[-2]}.{r[-1]}"]
14941504
return tuple(r)
14951505

14961506
results = {process(k): v for k, v in results.items()}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# generated by datamodel-codegen:
2+
# filename: treat_dot_as_module_single
3+
# timestamp: 2019-07-26T00:00:00+00:00
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# generated by datamodel-codegen:
2+
# filename: treat_dot_as_module_single
3+
# timestamp: 2019-07-26T00:00:00+00:00
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# generated by datamodel-codegen:
2+
# filename: model.schema.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Optional
8+
9+
from pydantic import BaseModel
10+
11+
12+
class User(BaseModel):
13+
name: str
14+
age: Optional[int] = None
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "User",
4+
"type": "object",
5+
"properties": {
6+
"name": {
7+
"type": "string"
8+
},
9+
"age": {
10+
"type": "integer"
11+
}
12+
},
13+
"required": ["name"]
14+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,6 +1808,16 @@ def test_treat_dot_as_module(as_module: bool, output_dir: Path) -> None:
18081808
)
18091809

18101810

1811+
def test_treat_dot_as_module_single_file(output_dir: Path) -> None:
1812+
"""Test treat-dot-as-module with single file having short path."""
1813+
run_main_and_assert(
1814+
input_path=JSON_SCHEMA_DATA_PATH / "treat_dot_as_module_single",
1815+
output_path=output_dir,
1816+
expected_directory=EXPECTED_JSON_SCHEMA_PATH / "treat_dot_as_module_single",
1817+
extra_args=["--treat-dot-as-module"],
1818+
)
1819+
1820+
18111821
def test_main_jsonschema_duplicate_name(output_dir: Path) -> None:
18121822
"""Test duplicate name handling."""
18131823
run_main_and_assert(

tests/parser/test_base.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
exact_import,
1818
relative,
1919
sort_data_models,
20+
to_hashable,
2021
)
2122
from datamodel_code_generator.reference import Reference, snake_to_upper_camel
2223
from datamodel_code_generator.types import DataType
@@ -512,3 +513,75 @@ def test_use_non_positive_negative_number_constrained_types(flag: bool) -> None:
512513
instance = C(source="", use_non_positive_negative_number_constrained_types=flag)
513514

514515
assert instance.data_type_manager.use_non_positive_negative_number_constrained_types == flag
516+
517+
518+
def test_to_hashable_simple_values() -> None:
519+
"""Test to_hashable with simple values."""
520+
assert to_hashable("string") == "string"
521+
assert to_hashable(123) == 123
522+
assert to_hashable(None) == "" # noqa: PLC1901
523+
524+
525+
def test_to_hashable_list_and_tuple() -> None:
526+
"""Test to_hashable with list and tuple."""
527+
result = to_hashable([3, 1, 2])
528+
assert isinstance(result, tuple)
529+
assert result == (1, 2, 3) # sorted
530+
531+
result = to_hashable((3, 1, 2))
532+
assert isinstance(result, tuple)
533+
assert result == (1, 2, 3) # sorted
534+
535+
536+
def test_to_hashable_dict() -> None:
537+
"""Test to_hashable with dict."""
538+
result = to_hashable({"b": 2, "a": 1})
539+
assert isinstance(result, tuple)
540+
# sorted by key
541+
assert result == (("a", 1), ("b", 2))
542+
543+
544+
def test_to_hashable_mixed_types_fallback() -> None:
545+
"""Test to_hashable with mixed types that cannot be compared."""
546+
mixed_list = [complex(1, 2), complex(3, 4)]
547+
result = to_hashable(mixed_list)
548+
assert isinstance(result, tuple)
549+
# Should preserve order since sorting fails
550+
assert result == (complex(1, 2), complex(3, 4))
551+
552+
553+
def test_to_hashable_nested_structures() -> None:
554+
"""Test to_hashable with nested structures."""
555+
nested = {"outer": [{"inner": 1}]}
556+
result = to_hashable(nested)
557+
assert isinstance(result, tuple)
558+
559+
560+
def test_postprocess_result_modules_single_element_tuple() -> None:
561+
"""Test postprocessing with single element tuple (len < 2)."""
562+
input_data = {
563+
("__init__.py",): "init_content",
564+
}
565+
result = Parser._Parser__postprocess_result_modules(input_data)
566+
# Single element tuple should remain unchanged
567+
assert ("__init__.py",) in result
568+
569+
570+
def test_postprocess_result_modules_single_file_no_dot() -> None:
571+
"""Test postprocessing with single file without dot in name."""
572+
input_data = {
573+
("module.py",): "content",
574+
("__init__.py",): "init_content",
575+
}
576+
result = Parser._Parser__postprocess_result_modules(input_data)
577+
assert ("module.py",) in result
578+
579+
580+
def test_postprocess_result_modules_single_element_no_dot() -> None:
581+
"""Test postprocessing with single element without dot (len(r) < 2 branch)."""
582+
input_data = {
583+
("__init__.py",): "init_content",
584+
("file",): "content", # Single element without dot, so len(r) = 1
585+
}
586+
result = Parser._Parser__postprocess_result_modules(input_data)
587+
assert ("file",) in result

0 commit comments

Comments
 (0)