Skip to content

Commit 5cdacb4

Browse files
authored
Guard generation imports cache contract (#3559)
1 parent a4e8215 commit 5cdacb4

1 file changed

Lines changed: 288 additions & 1 deletion

File tree

tests/parser/test_generation.py

Lines changed: 288 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,50 @@
99
from datamodel_code_generator.imports import IMPORT_DECIMAL, IMPORT_LIST, IMPORT_SET
1010
from datamodel_code_generator.model.base import BaseClassDataType
1111
from datamodel_code_generator.model.pydantic_v2 import BaseModel, DataModelField
12-
from datamodel_code_generator.parser.generation import GenerationStore, set_model_base_classes
12+
from datamodel_code_generator.parser.generation import (
13+
GENERATION_STORE_MUTATION_METHODS,
14+
GenerationStore,
15+
set_model_base_classes,
16+
)
1317
from datamodel_code_generator.reference import Reference
1418
from datamodel_code_generator.types import DataType
1519

20+
IMPORT_CACHE_CLEARING_MUTATION_METHODS = frozenset({
21+
"append_field",
22+
"collapse_root_data_type",
23+
"detach_data_type_ref",
24+
"detach_model_data_type_refs",
25+
"insert_field",
26+
"move_model",
27+
"redirect_model_reference_users",
28+
"redirect_reference_users",
29+
"remove_field",
30+
"rename_model",
31+
"replace_data_type_ref",
32+
"replace_field_type",
33+
"replace_nested_data_type",
34+
"reset_base_classes",
35+
"set_base_classes",
36+
"set_fields",
37+
"set_nested_data_types",
38+
"update_model_reference",
39+
})
40+
IMPORT_CACHE_NEUTRAL_MUTATION_METHODS = frozenset({
41+
"defer_refresh",
42+
"register_model",
43+
})
44+
45+
46+
def _base_model(name: str = "Model", fields: list[DataModelField] | None = None) -> BaseModel:
47+
return BaseModel(fields=fields or [], reference=Reference(path=name, original_name=name, name=name))
48+
49+
50+
def test_generation_store_import_cache_contract_covers_mutation_surface() -> None:
51+
"""New store mutation APIs must be classified for import-cache safety."""
52+
assert (
53+
IMPORT_CACHE_CLEARING_MUTATION_METHODS | IMPORT_CACHE_NEUTRAL_MUTATION_METHODS
54+
) == GENERATION_STORE_MUTATION_METHODS
55+
1656

1757
def test_generation_store_indexes_model_and_reference_order() -> None:
1858
"""Store facts preserve append order and expose model dependencies."""
@@ -621,6 +661,253 @@ def test_generation_store_field_type_replacement_clears_model_imports_cache() ->
621661
assert IMPORT_SET in model.imports
622662

623663

664+
def test_generation_store_model_mutations_clear_cached_imports_contract() -> None:
665+
"""Model-level store mutations must clear an already populated imports cache."""
666+
model = _base_model()
667+
store = GenerationStore()
668+
store.register_model(model)
669+
670+
assert model.imports is not None
671+
assert model._IMPORTS_CACHE_KEY in model.__dict__
672+
store.update_model_reference(model, reference_name="Renamed")
673+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
674+
675+
assert model.imports is not None
676+
assert model._IMPORTS_CACHE_KEY in model.__dict__
677+
store.rename_model(model, reference_name="RenamedAgain")
678+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
679+
680+
assert model.imports is not None
681+
assert model._IMPORTS_CACHE_KEY in model.__dict__
682+
store.move_model(model, new_path="pkg.RenamedAgain", new_file_path=Path("pkg.py"))
683+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
684+
685+
assert model.imports is not None
686+
assert model._IMPORTS_CACHE_KEY in model.__dict__
687+
store.set_base_classes(model, [BaseClassDataType(type="object")])
688+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
689+
690+
assert model.imports is not None
691+
assert model._IMPORTS_CACHE_KEY in model.__dict__
692+
store.reset_base_classes(model)
693+
assert {
694+
"cache_key": model._IMPORTS_CACHE_KEY in model.__dict__,
695+
"path": model.path,
696+
"reference_name": model.reference.name,
697+
"file_path": model.file_path,
698+
} == snapshot({
699+
"cache_key": False,
700+
"path": "pkg.RenamedAgain",
701+
"reference_name": "RenamedAgain",
702+
"file_path": Path("pkg.py"),
703+
})
704+
705+
706+
def test_generation_store_field_collection_mutations_clear_cached_imports_contract() -> None:
707+
"""Field collection helpers must invalidate stale model imports."""
708+
model = _base_model()
709+
store = GenerationStore()
710+
store.register_model(model)
711+
list_field = DataModelField(data_type=DataType(is_list=True))
712+
set_field = DataModelField(data_type=DataType(is_set=True))
713+
714+
assert model.imports is not None
715+
assert model._IMPORTS_CACHE_KEY in model.__dict__
716+
store.append_field(model, list_field)
717+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
718+
assert IMPORT_LIST in model.imports
719+
720+
assert model._IMPORTS_CACHE_KEY in model.__dict__
721+
store.insert_field(model, 0, set_field)
722+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
723+
assert IMPORT_LIST in model.imports
724+
assert IMPORT_SET in model.imports
725+
726+
assert model._IMPORTS_CACHE_KEY in model.__dict__
727+
store.remove_field(model, list_field)
728+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
729+
assert IMPORT_LIST not in model.imports
730+
assert IMPORT_SET in model.imports
731+
732+
assert model._IMPORTS_CACHE_KEY in model.__dict__
733+
store.set_fields(model, [DataModelField(data_type=DataType.from_import(IMPORT_DECIMAL))])
734+
assert {
735+
"cache_key": model._IMPORTS_CACHE_KEY in model.__dict__,
736+
"has_decimal": IMPORT_DECIMAL in model.imports,
737+
"has_set": IMPORT_SET in model.imports,
738+
"list_parent": list_field.parent is None,
739+
"set_parent": set_field.parent is None,
740+
} == snapshot({
741+
"cache_key": False,
742+
"has_decimal": True,
743+
"has_set": False,
744+
"list_parent": True,
745+
"set_parent": True,
746+
})
747+
748+
749+
def test_generation_store_data_type_mutations_clear_cached_imports_contract() -> None:
750+
"""DataType helpers must clear the cache for the owner model they mutate."""
751+
old_reference = Reference(path="Old", original_name="Old", name="Old")
752+
new_reference = Reference(path="New", original_name="New", name="New")
753+
data_type = DataType(reference=old_reference)
754+
field = DataModelField(data_type=data_type)
755+
model = _base_model(fields=[field])
756+
store = GenerationStore()
757+
store.register_model(model)
758+
759+
assert model.imports is not None
760+
assert model._IMPORTS_CACHE_KEY in model.__dict__
761+
store.replace_data_type_ref(data_type, new_reference)
762+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
763+
assert data_type.reference is new_reference
764+
765+
assert model.imports is not None
766+
assert model._IMPORTS_CACHE_KEY in model.__dict__
767+
store.detach_data_type_ref(data_type)
768+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
769+
assert data_type.reference is None
770+
771+
assert model.imports is not None
772+
assert model._IMPORTS_CACHE_KEY in model.__dict__
773+
store.replace_field_type(field, DataType(is_set=True))
774+
assert {
775+
"cache_key": model._IMPORTS_CACHE_KEY in model.__dict__,
776+
"has_set": IMPORT_SET in model.imports,
777+
"field_parent": field.data_type.parent is field,
778+
} == snapshot({
779+
"cache_key": False,
780+
"has_set": True,
781+
"field_parent": True,
782+
})
783+
784+
785+
def test_generation_store_nested_data_type_mutations_clear_cached_imports_contract() -> None:
786+
"""Nested DataType helpers must invalidate the imports cache of the outer model."""
787+
list_type = DataType(is_list=True, data_types=[DataType(type="str")])
788+
model = _base_model(fields=[DataModelField(data_type=list_type)])
789+
store = GenerationStore()
790+
store.register_model(model)
791+
792+
assert model.imports is not None
793+
assert model._IMPORTS_CACHE_KEY in model.__dict__
794+
store.set_nested_data_types(list_type, [DataType.from_import(IMPORT_DECIMAL)])
795+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
796+
assert IMPORT_DECIMAL in model.imports
797+
798+
nested_type = list_type.data_types[0]
799+
assert model._IMPORTS_CACHE_KEY in model.__dict__
800+
store.replace_nested_data_type(list_type, nested_type, DataType(type="str"))
801+
assert {
802+
"cache_key": model._IMPORTS_CACHE_KEY in model.__dict__,
803+
"has_decimal": IMPORT_DECIMAL in model.imports,
804+
"nested_types": [nested.type for nested in list_type.data_types],
805+
} == snapshot({
806+
"cache_key": False,
807+
"has_decimal": False,
808+
"nested_types": ["str"],
809+
})
810+
811+
812+
def test_generation_store_reference_detach_helpers_clear_cached_imports_contract() -> None:
813+
"""Bulk reference helpers must clear the cache through their delegated mutations."""
814+
reference = Reference(path="Referenced", original_name="Referenced", name="Referenced")
815+
root_type = DataType(reference=reference)
816+
model = _base_model(fields=[DataModelField(data_type=root_type)])
817+
store = GenerationStore()
818+
store.register_model(model)
819+
820+
assert model.imports is not None
821+
assert model._IMPORTS_CACHE_KEY in model.__dict__
822+
store.collapse_root_data_type(root_type, Reference(path="Inner", original_name="Inner", name="Inner"))
823+
assert model._IMPORTS_CACHE_KEY not in model.__dict__
824+
assert root_type.reference is not None
825+
826+
assert model.imports is not None
827+
assert model._IMPORTS_CACHE_KEY in model.__dict__
828+
store.detach_model_data_type_refs(model)
829+
assert {
830+
"cache_key": model._IMPORTS_CACHE_KEY in model.__dict__,
831+
"root_reference": root_type.reference,
832+
} == snapshot({
833+
"cache_key": False,
834+
"root_reference": None,
835+
})
836+
837+
838+
def test_generation_store_reference_redirects_clear_each_owner_imports_cache_once() -> None:
839+
"""Reference redirects should clear each affected owner model once."""
840+
old_reference = Reference(path="Old", original_name="Old", name="Old")
841+
new_reference = Reference(path="New", original_name="New", name="New")
842+
model = _base_model(
843+
fields=[
844+
DataModelField(data_type=DataType(reference=old_reference)),
845+
DataModelField(data_type=DataType(reference=old_reference)),
846+
]
847+
)
848+
store = GenerationStore()
849+
store.register_model(model)
850+
assert model.imports is not None
851+
assert model._IMPORTS_CACHE_KEY in model.__dict__
852+
853+
store.redirect_reference_users(old_reference, new_reference)
854+
855+
assert {
856+
"cache_key": model._IMPORTS_CACHE_KEY in model.__dict__,
857+
"field_references": [field.data_type.reference.path for field in model.fields if field.data_type.reference],
858+
"old_children": old_reference.children,
859+
"new_children": len(new_reference.children),
860+
} == snapshot({
861+
"cache_key": False,
862+
"field_references": ["New", "New"],
863+
"old_children": [],
864+
"new_children": 2,
865+
})
866+
867+
868+
def test_generation_store_model_reference_redirect_clears_only_affected_owner_imports_cache() -> None:
869+
"""Scoped model reference redirects should invalidate only matching owner models."""
870+
target_model = _base_model("Target")
871+
owner_model = _base_model(
872+
"Owner",
873+
fields=[
874+
DataModelField(data_type=DataType(reference=target_model.reference)),
875+
DataModelField(data_type=DataType(reference=target_model.reference)),
876+
],
877+
)
878+
other_model = _base_model("Other", fields=[DataModelField(data_type=DataType(reference=target_model.reference))])
879+
store = GenerationStore()
880+
store.register_model(target_model)
881+
store.register_model(owner_model)
882+
store.register_model(other_model)
883+
assert owner_model.imports is not None
884+
assert other_model.imports is not None
885+
assert owner_model._IMPORTS_CACHE_KEY in owner_model.__dict__
886+
assert other_model._IMPORTS_CACHE_KEY in other_model.__dict__
887+
888+
store.redirect_model_reference_users(
889+
target_model,
890+
[owner_model],
891+
Reference(path="NewTarget", original_name="NewTarget", name="NewTarget"),
892+
)
893+
894+
assert {
895+
"owner_cache_key": owner_model._IMPORTS_CACHE_KEY in owner_model.__dict__,
896+
"other_cache_key": other_model._IMPORTS_CACHE_KEY in other_model.__dict__,
897+
"owner_references": [
898+
field.data_type.reference.path for field in owner_model.fields if field.data_type.reference
899+
],
900+
"other_references": [
901+
field.data_type.reference.path for field in other_model.fields if field.data_type.reference
902+
],
903+
} == snapshot({
904+
"owner_cache_key": False,
905+
"other_cache_key": True,
906+
"owner_references": ["NewTarget", "NewTarget"],
907+
"other_references": ["Target"],
908+
})
909+
910+
624911
def test_generation_store_redirects_model_reference_users_by_owner() -> None:
625912
"""Reference redirection should only affect children owned by the requested models."""
626913
reference_target = Reference(path="Target", original_name="Target", name="Target")

0 commit comments

Comments
 (0)