Skip to content

Commit 1e5d185

Browse files
authored
Add generation dependency index (#3154)
* Add generation dependency index * Fix generation store checks * Fix generation store usage hook * Improve generation coverage tests
1 parent 5b66f67 commit 1e5d185

13 files changed

Lines changed: 1977 additions & 163 deletions

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,9 @@ repos:
5353
language: system
5454
files: ^(src/datamodel_code_generator/parser/schema_version\.py|scripts/build_schema_docs\.py|docs/supported_formats\.md)$
5555
pass_filenames: false
56+
- id: generation-store-usage
57+
name: Check parser GenerationStore usage
58+
entry: python scripts/check_generation_store_usage.py
59+
language: python
60+
files: ^src/datamodel_code_generator/parser/.*\.py$
61+
pass_filenames: false

docs/development-contributing.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,37 @@ assertions are also rejected. Only use `@pytest.mark.allow_direct_assert` for
8484
cases that cannot reasonably be expressed with the shared helpers, such as
8585
mock setup internals or narrow intermediate-state checks.
8686

87+
## Parser generation state
88+
89+
Parser code must keep generated output, naming order, parse order, and
90+
canonical model selection compatible with existing releases. `ModelResolver`
91+
remains the only authority for names. Do not move name generation or parser
92+
dispatch into the generation index.
93+
94+
Use `GenerationStore` for parser-side mutations that affect generation facts:
95+
96+
- Register parsed models with `generation_store.register_model(model)`.
97+
- Replace references with `generation_store.replace_data_type_ref(...)` or
98+
`generation_store.detach_data_type_ref(...)`.
99+
- Replace field or nested data types with `replace_field_type(...)`,
100+
`replace_nested_data_type(...)`, or `set_nested_data_types(...)`.
101+
- Replace fields or base classes with `set_fields(...)`,
102+
`append_field(...)`, `insert_field(...)`, `remove_field(...)`, or
103+
`set_base_classes(...)`.
104+
- Move or rename generated models with `move_model(...)`,
105+
`rename_model(...)`, or `update_model_reference(...)`.
106+
107+
Parser code should not directly assign `data_type.reference`, `model.fields`,
108+
`model.base_classes`, `model.reference.name`, or call reference child mutation
109+
helpers. Read dependency information through `GenerationIndex` queries instead
110+
of using `Reference.children` as a reverse index.
111+
112+
The local pre-commit hook `generation-store-usage` runs
113+
`scripts/check_generation_store_usage.py` and rejects common parser mutations
114+
that bypass the store. If a new mutation helper is needed, add the method to
115+
`GENERATION_STORE_MUTATION_METHODS` and update the checker tests so contributors
116+
get an actionable message.
117+
87118
## ➕ Adding a New CLI Option
88119

89120
When adding a new CLI option to `datamodel-code-generator`, follow these steps:
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
"""Reject parser mutations that bypass GenerationStore."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import ast
7+
import sys
8+
from dataclasses import dataclass
9+
from pathlib import Path
10+
from typing import TypeAlias
11+
12+
ROOT = Path(__file__).resolve().parents[1]
13+
14+
DEFAULT_TARGET = Path("src/datamodel_code_generator/parser")
15+
GENERATION_STORE_MODULE = Path("src/datamodel_code_generator/parser/generation.py")
16+
17+
MUTATION_ATTRIBUTES = frozenset({"base_classes", "data_type", "fields", "reference"})
18+
MODEL_COLLECTION_ATTRIBUTES = frozenset({"models", "results"})
19+
MODEL_COLLECTION_HELPERS = {"append": "register_model"}
20+
REFERENCE_METHODS = frozenset(
21+
{
22+
"_invalidate_after_mutation",
23+
"_refresh_after_mutation",
24+
"remove_reference",
25+
"replace_children_in_models",
26+
"replace_children_references",
27+
"replace_data_type",
28+
"replace_reference",
29+
"refresh_after_mutation",
30+
"set_reference_path",
31+
},
32+
)
33+
SEQUENCE_MUTATORS = frozenset({"append", "clear", "extend", "insert", "pop", "remove"})
34+
SequenceOwner: TypeAlias = str
35+
36+
37+
def _literal_string_set(node: ast.AST) -> frozenset[str]:
38+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "frozenset" and node.args:
39+
node = node.args[0]
40+
if not isinstance(node, ast.Set):
41+
msg = "GENERATION_STORE_MUTATION_METHODS must be a literal frozenset of strings"
42+
raise TypeError(msg)
43+
44+
values: set[str] = set()
45+
for item in node.elts:
46+
if not isinstance(item, ast.Constant) or not isinstance(item.value, str):
47+
msg = "GENERATION_STORE_MUTATION_METHODS must contain only literal strings"
48+
raise TypeError(msg)
49+
values.add(item.value)
50+
return frozenset(values)
51+
52+
53+
def _load_generation_store_mutation_methods() -> frozenset[str]:
54+
generation_store_module = ROOT / GENERATION_STORE_MODULE
55+
tree = ast.parse(generation_store_module.read_text(), filename=str(generation_store_module))
56+
for node in tree.body:
57+
if (
58+
isinstance(node, ast.AnnAssign)
59+
and isinstance(node.target, ast.Name)
60+
and node.target.id == "GENERATION_STORE_MUTATION_METHODS"
61+
and node.value is not None
62+
):
63+
return _literal_string_set(node.value)
64+
if isinstance(node, ast.Assign):
65+
for target in node.targets:
66+
if isinstance(target, ast.Name) and target.id == "GENERATION_STORE_MUTATION_METHODS":
67+
return _literal_string_set(node.value)
68+
msg = "GENERATION_STORE_MUTATION_METHODS was not found"
69+
raise RuntimeError(msg)
70+
71+
72+
GENERATION_STORE_MUTATION_METHODS = _load_generation_store_mutation_methods()
73+
74+
75+
@dataclass(frozen=True)
76+
class Violation:
77+
"""A direct parser mutation that should go through GenerationStore."""
78+
79+
path: Path
80+
line: int
81+
column: int
82+
message: str
83+
84+
def format(self) -> str:
85+
"""Format violation for CLI output."""
86+
return f"{self.path}:{self.line}:{self.column}: {self.message}"
87+
88+
89+
def _attribute_chain(node: ast.AST) -> tuple[str, ...]:
90+
names: list[str] = []
91+
current = node
92+
while isinstance(current, ast.Attribute):
93+
names.append(current.attr)
94+
current = current.value
95+
if isinstance(current, ast.Name):
96+
names.append(current.id)
97+
names.reverse()
98+
return tuple(names)
99+
100+
101+
def _sequence_owner(node: ast.AST) -> SequenceOwner | None:
102+
if isinstance(node, ast.Attribute) and node.attr in {"base_classes", "fields", *MODEL_COLLECTION_ATTRIBUTES}:
103+
return node.attr
104+
if isinstance(node, ast.Subscript):
105+
return _sequence_owner(node.value)
106+
return None
107+
108+
109+
class GenerationStoreUsageVisitor(ast.NodeVisitor):
110+
"""AST visitor for parser code that must use GenerationStore mutation APIs."""
111+
112+
def __init__(self, path: Path) -> None:
113+
"""Create a visitor for ``path``."""
114+
self.path = path
115+
self.violations: list[Violation] = []
116+
117+
def visit_Assign(self, node: ast.Assign) -> None:
118+
"""Check assignment targets."""
119+
for target in node.targets:
120+
self._check_assignment_target(target)
121+
self.generic_visit(node)
122+
123+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
124+
"""Check annotated assignment targets."""
125+
self._check_assignment_target(node.target)
126+
self.generic_visit(node)
127+
128+
def visit_AugAssign(self, node: ast.AugAssign) -> None:
129+
"""Check augmented assignment targets."""
130+
self._check_assignment_target(node.target)
131+
self.generic_visit(node)
132+
133+
def visit_Call(self, node: ast.Call) -> None:
134+
"""Check direct mutating method calls."""
135+
if isinstance(node.func, ast.Attribute):
136+
method = node.func.attr
137+
owner = _sequence_owner(node.func.value)
138+
if owner and method in SEQUENCE_MUTATORS:
139+
self._add(
140+
node,
141+
f"use GenerationStore.{self._sequence_helper(owner, method)}() instead of mutating {owner}",
142+
)
143+
elif method in REFERENCE_METHODS:
144+
self._add(node, f"use GenerationStore API instead of {method}()")
145+
self.generic_visit(node)
146+
147+
def visit_Attribute(self, node: ast.Attribute) -> None:
148+
"""Check direct ``Reference.children`` reads."""
149+
if node.attr == "children" and isinstance(node.ctx, ast.Load):
150+
self._add(node, "use GenerationIndex reverse-edge queries instead of Reference.children")
151+
self.generic_visit(node)
152+
153+
def _check_assignment_target(self, target: ast.AST) -> None:
154+
owner = _sequence_owner(target)
155+
if owner:
156+
helper = self._sequence_helper(owner, "assign")
157+
self._add(target, f"use GenerationStore.{helper}() instead of assigning {owner}")
158+
return
159+
160+
if not isinstance(target, ast.Attribute):
161+
return
162+
163+
chain = _attribute_chain(target)
164+
if not chain:
165+
return
166+
167+
if target.attr in MUTATION_ATTRIBUTES or (
168+
isinstance(target.value, ast.Attribute) and target.value.attr == "reference"
169+
):
170+
self._add(target, f"use GenerationStore API instead of assigning {'.'.join(chain[-2:])}")
171+
172+
def _add(self, node: ast.AST, message: str) -> None:
173+
self.violations.append(
174+
Violation(
175+
path=self.path,
176+
line=getattr(node, "lineno", 0),
177+
column=getattr(node, "col_offset", 0) + 1,
178+
message=message,
179+
)
180+
)
181+
182+
@staticmethod
183+
def _sequence_helper(owner: SequenceOwner, method: str) -> str:
184+
if owner in MODEL_COLLECTION_ATTRIBUTES:
185+
helper = MODEL_COLLECTION_HELPERS.get(method, "register_model")
186+
return _ensure_generation_store_api(helper)
187+
if owner == "fields":
188+
helper = {
189+
"assign": "set_fields",
190+
"append": "append_field",
191+
"insert": "insert_field",
192+
"remove": "remove_field",
193+
}.get(method, "set_fields")
194+
return _ensure_generation_store_api(helper)
195+
return _ensure_generation_store_api("set_base_classes")
196+
197+
198+
def _ensure_generation_store_api(method_name: str) -> str:
199+
if method_name not in GENERATION_STORE_MUTATION_METHODS:
200+
msg = f"{method_name!r} is not a registered GenerationStore mutation API"
201+
raise RuntimeError(msg)
202+
return method_name
203+
204+
205+
def iter_python_files(paths: list[Path]) -> list[Path]:
206+
"""Return checked Python files below ``paths``."""
207+
files: list[Path] = []
208+
for path in paths:
209+
if path.is_dir():
210+
files.extend(sorted(path.rglob("*.py")))
211+
elif path.suffix == ".py":
212+
files.append(path)
213+
generation_store_module = GENERATION_STORE_MODULE.resolve()
214+
return [path for path in files if path.resolve() != generation_store_module]
215+
216+
217+
def check_paths(paths: list[Path]) -> list[Violation]:
218+
"""Check paths and return all GenerationStore usage violations."""
219+
violations: list[Violation] = []
220+
for path in iter_python_files(paths):
221+
tree = ast.parse(path.read_text(), filename=str(path))
222+
visitor = GenerationStoreUsageVisitor(path)
223+
visitor.visit(tree)
224+
violations.extend(visitor.violations)
225+
return violations
226+
227+
228+
def main(argv: list[str] | None = None) -> int:
229+
"""CLI entry point."""
230+
parser = argparse.ArgumentParser(description=__doc__)
231+
parser.add_argument("paths", nargs="*", type=Path, default=[DEFAULT_TARGET])
232+
args = parser.parse_args(argv)
233+
234+
violations = check_paths(args.paths)
235+
if violations:
236+
print("GenerationStore usage violations found:", file=sys.stderr)
237+
for violation in violations:
238+
print(violation.format(), file=sys.stderr)
239+
return 1
240+
return 0
241+
242+
243+
if __name__ == "__main__":
244+
raise SystemExit(main())

0 commit comments

Comments
 (0)