model-generator-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. model_generator/__init__.py +6 -0
  2. model_generator/generate.py +1030 -0
  3. model_generator/generators/__init__.py +38 -0
  4. model_generator/generators/api.py +287 -0
  5. model_generator/generators/constraints.py +176 -0
  6. model_generator/generators/database.py +147 -0
  7. model_generator/generators/enums.py +88 -0
  8. model_generator/generators/infrastructure.py +679 -0
  9. model_generator/generators/migrations.py +146 -0
  10. model_generator/py.typed +0 -0
  11. model_generator/schema/model.schema.json +758 -0
  12. model_generator/stacks/python-fastapi/config.yaml +403 -0
  13. model_generator/stacks/python-fastapi/templates/_shared/_base.j2 +26 -0
  14. model_generator/stacks/python-fastapi/templates/_shared/_entity.j2 +48 -0
  15. model_generator/stacks/python-fastapi/templates/_shared/_examples.j2 +50 -0
  16. model_generator/stacks/python-fastapi/templates/_shared/_fields.j2 +48 -0
  17. model_generator/stacks/python-fastapi/templates/_shared/_tests.j2 +143 -0
  18. model_generator/stacks/python-fastapi/templates/api/init.py.j2 +55 -0
  19. model_generator/stacks/python-fastapi/templates/api/pagination.py.j2 +79 -0
  20. model_generator/stacks/python-fastapi/templates/api/request.py.j2 +448 -0
  21. model_generator/stacks/python-fastapi/templates/api/response.py.j2 +222 -0
  22. model_generator/stacks/python-fastapi/templates/api/route.py.j2 +507 -0
  23. model_generator/stacks/python-fastapi/templates/database/constraints.py.j2 +439 -0
  24. model_generator/stacks/python-fastapi/templates/database/enums.py.j2 +55 -0
  25. model_generator/stacks/python-fastapi/templates/database/factory.py.j2 +265 -0
  26. model_generator/stacks/python-fastapi/templates/database/init.py.j2 +37 -0
  27. model_generator/stacks/python-fastapi/templates/database/model.py.j2 +476 -0
  28. model_generator/stacks/python-fastapi/templates/infrastructure/auth_router.py.j2 +434 -0
  29. model_generator/stacks/python-fastapi/templates/infrastructure/base.py.j2 +16 -0
  30. model_generator/stacks/python-fastapi/templates/infrastructure/csrf.py.j2 +121 -0
  31. model_generator/stacks/python-fastapi/templates/infrastructure/database_init.py.j2 +12 -0
  32. model_generator/stacks/python-fastapi/templates/infrastructure/encrypted_bytes.py.j2 +62 -0
  33. model_generator/stacks/python-fastapi/templates/infrastructure/engine.py.j2 +51 -0
  34. model_generator/stacks/python-fastapi/templates/infrastructure/errors.py.j2 +74 -0
  35. model_generator/stacks/python-fastapi/templates/infrastructure/gitignore.j2 +48 -0
  36. model_generator/stacks/python-fastapi/templates/infrastructure/main.py.j2 +94 -0
  37. model_generator/stacks/python-fastapi/templates/infrastructure/pyproject.toml.j2 +92 -0
  38. model_generator/stacks/python-fastapi/templates/infrastructure/rate_limit.py.j2 +41 -0
  39. model_generator/stacks/python-fastapi/templates/infrastructure/types.py.j2 +94 -0
  40. model_generator/stacks/python-fastapi/templates/infrastructure/utils.py.j2 +50 -0
  41. model_generator/stacks/python-fastapi/templates/infrastructure/validators.py.j2 +126 -0
  42. model_generator/stacks/python-fastapi/templates/migrations/env.py.j2 +125 -0
  43. model_generator/stacks/python-fastapi/templates/migrations/ini.j2 +109 -0
  44. model_generator/stacks/python-fastapi/templates/migrations/script.py.mako.j2 +35 -0
  45. model_generator/stacks/python-fastapi/templates/tests/conftest_root.py.j2 +122 -0
  46. model_generator/stacks/python-fastapi/templates/tests/contract.py.j2 +1860 -0
  47. model_generator/utils/__init__.py +31 -0
  48. model_generator/utils/conftest_generator.py +683 -0
  49. model_generator/utils/constants.py +6 -0
  50. model_generator/utils/loaders.py +292 -0
  51. model_generator/utils/parser.py +129 -0
  52. model_generator/utils/quality.py +43 -0
  53. model_generator/utils/templates.py +128 -0
  54. model_generator/validate.py +219 -0
  55. model_generator/wizard/__init__.py +10 -0
  56. model_generator/wizard/actions/__init__.py +1 -0
  57. model_generator/wizard/actions/clean.py +55 -0
  58. model_generator/wizard/actions/generate.py +166 -0
  59. model_generator/wizard/actions/project_setup.py +142 -0
  60. model_generator/wizard/actions/test_runner.py +60 -0
  61. model_generator/wizard/menu.py +43 -0
  62. model_generator/wizard/prompts.py +80 -0
  63. model_generator_kit-0.1.0.dist-info/METADATA +143 -0
  64. model_generator_kit-0.1.0.dist-info/RECORD +68 -0
  65. model_generator_kit-0.1.0.dist-info/WHEEL +5 -0
  66. model_generator_kit-0.1.0.dist-info/entry_points.txt +3 -0
  67. model_generator_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
  68. model_generator_kit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,38 @@
1
+ """
2
+ Code generators for model-generator.
3
+ """
4
+
5
+ from .api import (
6
+ generate_api_init,
7
+ generate_api_models,
8
+ generate_api_pagination,
9
+ generate_api_routes,
10
+ generate_api_tests,
11
+ )
12
+ from .constraints import generate_constraints
13
+ from .database import generate_database_model, generate_factories, generate_init
14
+ from .enums import generate_enums
15
+ from .infrastructure import (
16
+ generate_gitignore,
17
+ generate_infrastructure,
18
+ generate_pyproject,
19
+ )
20
+ from .migrations import generate_migration_autogen, generate_migration_init
21
+
22
+ __all__ = [
23
+ "generate_api_init",
24
+ "generate_api_models",
25
+ "generate_api_pagination",
26
+ "generate_api_routes",
27
+ "generate_api_tests",
28
+ "generate_constraints",
29
+ "generate_database_model",
30
+ "generate_enums",
31
+ "generate_factories",
32
+ "generate_gitignore",
33
+ "generate_infrastructure",
34
+ "generate_init",
35
+ "generate_migration_autogen",
36
+ "generate_migration_init",
37
+ "generate_pyproject",
38
+ ]
@@ -0,0 +1,287 @@
1
+ """
2
+ API generation utilities (models, routes, tests).
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from jinja2 import Environment
9
+
10
+ from ..utils.loaders import get_layout, load_shared_constraints, load_shared_enums
11
+ from ..utils.parser import scan_api_model_files
12
+ from ..utils.templates import snake_case
13
+
14
+
15
+ def _filter_api_entities(model: dict[str, Any]) -> dict[str, Any] | None:
16
+ """Return model copy with only API-enabled entities, or None if none."""
17
+ entities = {
18
+ name: entity
19
+ for name, entity in model.get("entities", {}).items()
20
+ if entity.get("api", {}).get("enabled", True)
21
+ }
22
+ if not entities:
23
+ return None
24
+ return {**model, "entities": entities}
25
+
26
+
27
+ def _filter_test_entities(model: dict[str, Any]) -> dict[str, Any] | None:
28
+ """Return model copy with only test-enabled entities, or None if none."""
29
+ entities = {
30
+ name: entity
31
+ for name, entity in model.get("entities", {}).items()
32
+ if entity.get("tests", {}).get("enabled", True)
33
+ }
34
+ if not entities:
35
+ return None
36
+ return {**model, "entities": entities}
37
+
38
+
39
+ def generate_api_models(
40
+ model: dict[str, Any],
41
+ config: dict[str, Any],
42
+ env: Environment,
43
+ project_root: Path,
44
+ model_path: Path | None = None,
45
+ ) -> list[dict[str, Any]] | None:
46
+ """Generate Pydantic response and request models.
47
+
48
+ In per-entity layout (the default) returns two files per entity
49
+ (``{entity_snake}_response.py`` + ``{entity_snake}_requests.py``). In
50
+ per-domain layout returns one combined response file and one combined
51
+ request file. Templates iterate ``model.entities`` so per-entity mode
52
+ just feeds them sliced inputs — no template change required.
53
+ """
54
+ filtered = _filter_api_entities(model)
55
+ if filtered is None:
56
+ return None
57
+
58
+ enums = load_shared_enums(model_path or project_root)
59
+ output_dir = project_root / config["paths"]["api_models"]
60
+ response_template = env.get_template("api/response.py.j2")
61
+ request_template = env.get_template("api/request.py.j2")
62
+
63
+ if get_layout(config) == "per-entity":
64
+ outputs = []
65
+ for name, entity in filtered.get("entities", {}).items():
66
+ sliced = {**filtered, "entities": {name: entity}}
67
+ stem = snake_case(name)
68
+ outputs.append(
69
+ {
70
+ "path": output_dir / f"{stem}_response.py",
71
+ "content": response_template.render(
72
+ model=sliced, config=config, enums=enums
73
+ ),
74
+ }
75
+ )
76
+ outputs.append(
77
+ {
78
+ "path": output_dir / f"{stem}_requests.py",
79
+ "content": request_template.render(
80
+ model=sliced, config=config, enums=enums
81
+ ),
82
+ }
83
+ )
84
+ return outputs
85
+
86
+ domain = filtered.get("domain", "models")
87
+ return [
88
+ {
89
+ "path": output_dir / f"{domain}_response.py",
90
+ "content": response_template.render(
91
+ model=filtered, config=config, enums=enums
92
+ ),
93
+ },
94
+ {
95
+ "path": output_dir / f"{domain}_requests.py",
96
+ "content": request_template.render(
97
+ model=filtered, config=config, enums=enums
98
+ ),
99
+ },
100
+ ]
101
+
102
+
103
+ def generate_api_init(
104
+ model: dict[str, Any], config: dict[str, Any], env: Environment, project_root: Path
105
+ ) -> dict[str, Any] | None:
106
+ """Generate __init__.py with exports for all API model files."""
107
+ output_dir = project_root / config["paths"]["api_models"]
108
+ domains = scan_api_model_files(output_dir)
109
+ filtered = _filter_api_entities(model)
110
+
111
+ if get_layout(config) == "per-entity":
112
+ existing_names = {d["name"] for d in domains}
113
+ if filtered is not None:
114
+ for entity_name, entity in filtered.get("entities", {}).items():
115
+ stem = snake_case(entity_name)
116
+ if stem in existing_names:
117
+ continue
118
+ request_models = [f"Create{entity_name}Request"]
119
+ if entity.get("mutability", "mutable") != "immutable":
120
+ request_models.append(f"Update{entity_name}Request")
121
+ domains.append(
122
+ {
123
+ "name": stem,
124
+ "section": None,
125
+ "response_models": [f"{entity_name}Response"],
126
+ "request_models": request_models,
127
+ }
128
+ )
129
+ else:
130
+ current_domain = model.get("domain", "unknown")
131
+ domain_names = {d["name"] for d in domains}
132
+ if filtered is not None and current_domain not in domain_names:
133
+ entities = list(filtered.get("entities", {}).keys())
134
+ domains.append(
135
+ {
136
+ "name": current_domain,
137
+ "section": current_domain.upper().replace("_", " ") + " MODELS",
138
+ "response_models": [f"{e}Response" for e in entities],
139
+ "request_models": [f"Create{e}Request" for e in entities]
140
+ + [
141
+ f"Update{e}Request"
142
+ for e in entities
143
+ if filtered["entities"][e].get("mutability", "mutable")
144
+ != "immutable"
145
+ ],
146
+ }
147
+ )
148
+
149
+ if not domains:
150
+ print(f" ℹ️ No API model files found in {output_dir}")
151
+ return None
152
+
153
+ total_responses = sum(len(d["response_models"]) for d in domains)
154
+ total_requests = sum(len(d["request_models"]) for d in domains)
155
+
156
+ template = env.get_template("api/init.py.j2")
157
+ content = template.render(domains=domains, config=config)
158
+
159
+ return {
160
+ "path": output_dir / "__init__.py",
161
+ "content": content,
162
+ "mode": "write",
163
+ "domain_count": len(domains),
164
+ "response_count": total_responses,
165
+ "request_count": total_requests,
166
+ }
167
+
168
+
169
+ def generate_api_pagination(
170
+ model: dict[str, Any], config: dict[str, Any], env: Environment, project_root: Path
171
+ ) -> dict[str, Any] | None:
172
+ """Generate pagination.py with reusable pagination models."""
173
+ output_dir = project_root / config["paths"]["api_models"]
174
+ pagination_file = output_dir / "pagination.py"
175
+
176
+ if pagination_file.exists():
177
+ print(f" ℹ️ Pagination file already exists at {pagination_file}")
178
+ print(" To regenerate, delete the file first")
179
+ return None
180
+
181
+ template = env.get_template("api/pagination.py.j2")
182
+ content = template.render(config=config)
183
+
184
+ return {"path": pagination_file, "content": content, "mode": "write"}
185
+
186
+
187
+ def generate_api_routes(
188
+ model: dict[str, Any],
189
+ config: dict[str, Any],
190
+ env: Environment,
191
+ project_root: Path,
192
+ enums: dict[str, Any] | None = None,
193
+ constraints: dict[str, Any] | None = None,
194
+ model_path: Path | None = None,
195
+ ) -> dict[str, Any] | list[dict[str, Any]] | None:
196
+ """Generate FastAPI routes.
197
+
198
+ In per-entity layout (the default) returns one dict per entity at
199
+ ``routes/{entity_snake}.py``; in per-domain layout returns a single
200
+ dict at ``routes/{domain}.py``. The route template adapts its DB-model
201
+ and api-model imports based on ``config.generation.layout``.
202
+ """
203
+ filtered = _filter_api_entities(model)
204
+ if filtered is None:
205
+ return None
206
+
207
+ if enums is None:
208
+ enums = load_shared_enums(model_path or project_root)
209
+ if constraints is None:
210
+ constraints = load_shared_constraints(model_path or project_root)
211
+
212
+ template = env.get_template("api/route.py.j2")
213
+ output_dir = project_root / config["paths"]["api_routes"]
214
+
215
+ if get_layout(config) == "per-entity":
216
+ return [
217
+ {
218
+ "path": output_dir / f"{snake_case(name)}.py",
219
+ "content": template.render(
220
+ model={**filtered, "entities": {name: entity}},
221
+ config=config,
222
+ enums=enums,
223
+ constraints=constraints,
224
+ ),
225
+ }
226
+ for name, entity in filtered.get("entities", {}).items()
227
+ ]
228
+
229
+ domain = filtered.get("domain", "models")
230
+ content = template.render(
231
+ model=filtered, config=config, enums=enums, constraints=constraints
232
+ )
233
+ return {"path": output_dir / f"{domain}.py", "content": content}
234
+
235
+
236
+ def generate_api_tests(
237
+ model: dict[str, Any],
238
+ config: dict[str, Any],
239
+ env: Environment,
240
+ project_root: Path,
241
+ enums: dict[str, Any] | None = None,
242
+ constraints: dict[str, Any] | None = None,
243
+ model_path: Path | None = None,
244
+ ) -> dict[str, Any] | list[dict[str, Any]] | None:
245
+ """Generate API contract tests.
246
+
247
+ In per-entity layout (the default) returns one dict per entity at
248
+ ``tests/api/test_{entity_snake}_api.py``; in per-domain layout returns
249
+ a single dict at ``tests/api/test_{domain}_api.py``. The contract
250
+ template iterates ``model.entities`` and has no cross-entity imports,
251
+ so per-entity mode just feeds it sliced inputs — no template change.
252
+ """
253
+ # Both API and tests must be enabled
254
+ filtered = _filter_api_entities(model)
255
+ if filtered is None:
256
+ return None
257
+ filtered = _filter_test_entities(filtered)
258
+ if filtered is None:
259
+ return None
260
+
261
+ if enums is None:
262
+ enums = load_shared_enums(model_path or project_root)
263
+ if constraints is None:
264
+ constraints = load_shared_constraints(model_path or project_root)
265
+
266
+ template = env.get_template("tests/contract.py.j2")
267
+ output_dir = project_root / config["paths"]["api_tests"]
268
+
269
+ if get_layout(config) == "per-entity":
270
+ return [
271
+ {
272
+ "path": output_dir / f"test_{snake_case(name)}_api.py",
273
+ "content": template.render(
274
+ model={**filtered, "entities": {name: entity}},
275
+ config=config,
276
+ enums=enums,
277
+ constraints=constraints,
278
+ ),
279
+ }
280
+ for name, entity in filtered.get("entities", {}).items()
281
+ ]
282
+
283
+ domain = filtered.get("domain", "models")
284
+ content = template.render(
285
+ model=filtered, config=config, enums=enums, constraints=constraints
286
+ )
287
+ return {"path": output_dir / f"test_{domain}_api.py", "content": content}
@@ -0,0 +1,176 @@
1
+ """
2
+ Constraint generation utilities.
3
+ """
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from jinja2 import Environment
10
+
11
+ from ..utils.loaders import load_shared_constraints
12
+
13
+
14
+ def get_existing_constraints(constraints_file: Path) -> set[str]:
15
+ """Parse existing constraints.py to find defined constant names."""
16
+ if not constraints_file.exists():
17
+ return set()
18
+
19
+ content = constraints_file.read_text()
20
+ pattern = r"^([A-Z][A-Z0-9_]*)\s*="
21
+ return set(re.findall(pattern, content, re.MULTILINE))
22
+
23
+
24
+ def extract_constraint_refs(
25
+ model: dict[str, Any], shared_constraints: dict[str, Any]
26
+ ) -> list[dict[str, Any]]:
27
+ """Extract all constraint references from model field definitions."""
28
+ refs: list[dict[str, Any]] = []
29
+ seen: set[str] = set()
30
+
31
+ for entity in model.get("entities", {}).values():
32
+ for field_name, field in entity.get("fields", {}).items():
33
+ for constraint in field.get("constraints", []):
34
+ _extract_ref(
35
+ constraint,
36
+ "min_ref",
37
+ shared_constraints,
38
+ field_name,
39
+ refs,
40
+ seen,
41
+ is_min=True,
42
+ )
43
+ _extract_ref(
44
+ constraint,
45
+ "max_ref",
46
+ shared_constraints,
47
+ field_name,
48
+ refs,
49
+ seen,
50
+ is_min=False,
51
+ )
52
+ _extract_regex_ref(
53
+ constraint, shared_constraints, field_name, refs, seen
54
+ )
55
+
56
+ return refs
57
+
58
+
59
+ def _extract_ref(
60
+ constraint: dict[str, Any],
61
+ ref_key: str,
62
+ shared_constraints: dict[str, Any],
63
+ field_name: str,
64
+ refs: list[dict[str, Any]],
65
+ seen: set[str],
66
+ is_min: bool,
67
+ ) -> None:
68
+ """Extract min_ref or max_ref from constraint."""
69
+ if ref_key not in constraint or constraint[ref_key] in seen:
70
+ return
71
+
72
+ ref_name = constraint[ref_key]
73
+ ref_def = shared_constraints.get(ref_name, {})
74
+ refs.append(
75
+ {
76
+ "name": ref_name,
77
+ "type": ref_def.get("type", constraint.get("type", "decimal")),
78
+ "field": field_name,
79
+ "is_min": is_min,
80
+ "value": ref_def.get("value"),
81
+ "description": ref_def.get("description"),
82
+ }
83
+ )
84
+ seen.add(ref_name)
85
+
86
+
87
+ def _extract_regex_ref(
88
+ constraint: dict[str, Any],
89
+ shared_constraints: dict[str, Any],
90
+ field_name: str,
91
+ refs: list[dict[str, Any]],
92
+ seen: set[str],
93
+ ) -> None:
94
+ """Extract regex_ref from constraint."""
95
+ if "regex_ref" not in constraint or constraint["regex_ref"] in seen:
96
+ return
97
+
98
+ ref_name = constraint["regex_ref"]
99
+ ref_def = shared_constraints.get(ref_name, {})
100
+ refs.append(
101
+ {
102
+ "name": ref_name,
103
+ "type": "pattern",
104
+ "field": field_name,
105
+ "value": ref_def.get("value"),
106
+ "description": ref_def.get("description"),
107
+ }
108
+ )
109
+ seen.add(ref_name)
110
+
111
+
112
+ def generate_constraints(
113
+ model: dict[str, Any],
114
+ config: dict[str, Any],
115
+ env: Environment,
116
+ project_root: Path,
117
+ model_path: Path | None = None,
118
+ ) -> dict[str, Any] | None:
119
+ """
120
+ Generate constraint constants from _shared/constraints.json.
121
+
122
+ Creates file if missing, appends new constraints if file exists.
123
+ """
124
+ shared_constraints = load_shared_constraints(
125
+ model_path if model_path is not None else project_root
126
+ )
127
+ all_refs = extract_constraint_refs(model, shared_constraints)
128
+
129
+ if not all_refs:
130
+ return None
131
+
132
+ output_dir = project_root / config["paths"]["database_models"]
133
+ constraints_file = output_dir / "constraints.py"
134
+ file_exists = constraints_file.exists()
135
+
136
+ if file_exists:
137
+ existing = get_existing_constraints(constraints_file)
138
+ new_refs = [ref for ref in all_refs if ref["name"] not in existing]
139
+
140
+ if not new_refs:
141
+ print(f" ℹ️ All constraint constants already exist in {constraints_file}")
142
+ return None
143
+
144
+ template = env.get_template("database/constraints.py.j2")
145
+ content = template.render(
146
+ mode="append",
147
+ section_header="CONSTRAINTS",
148
+ constraints=new_refs,
149
+ include_helpers=False,
150
+ config=config,
151
+ )
152
+
153
+ return {
154
+ "path": constraints_file,
155
+ "content": "\n" + content,
156
+ "mode": "append",
157
+ "new_count": len(new_refs),
158
+ "skipped": len(all_refs) - len(new_refs),
159
+ }
160
+ else:
161
+ template = env.get_template("database/constraints.py.j2")
162
+ content = template.render(
163
+ mode="create",
164
+ section_header="CONSTRAINTS",
165
+ constraints=all_refs,
166
+ include_helpers=True,
167
+ config=config,
168
+ )
169
+
170
+ return {
171
+ "path": constraints_file,
172
+ "content": content,
173
+ "mode": "write",
174
+ "new_count": len(all_refs),
175
+ "skipped": 0,
176
+ }
@@ -0,0 +1,147 @@
1
+ """
2
+ Database model generation utilities.
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from jinja2 import Environment
9
+
10
+ from ..utils.loaders import get_layout, load_shared_constraints
11
+ from ..utils.parser import scan_model_files
12
+ from ..utils.templates import snake_case
13
+
14
+
15
+ def generate_database_model(
16
+ model: dict[str, Any], config: dict[str, Any], env: Environment, project_root: Path
17
+ ) -> dict[str, Any] | list[dict[str, Any]]:
18
+ """Generate SQLAlchemy database model(s).
19
+
20
+ In per-entity layout (the default) returns one dict per entity, each
21
+ pointing at ``{entity_snake}.py``. In per-domain layout returns a single
22
+ dict pointing at ``{domain}.py`` with all entities in one file.
23
+ """
24
+ template = env.get_template("database/model.py.j2")
25
+ output_dir = project_root / config["paths"]["database_models"]
26
+ sibling_entities = list(model.get("entities", {}).keys())
27
+
28
+ if get_layout(config) == "per-entity":
29
+ return [
30
+ {
31
+ "path": output_dir / f"{snake_case(name)}.py",
32
+ "content": template.render(
33
+ model={**model, "entities": {name: entity}},
34
+ config=config,
35
+ sibling_entities=sibling_entities,
36
+ ),
37
+ }
38
+ for name, entity in model.get("entities", {}).items()
39
+ ]
40
+
41
+ domain = model.get("domain", "models")
42
+ content = template.render(
43
+ model=model, config=config, sibling_entities=sibling_entities
44
+ )
45
+ return {"path": output_dir / f"{domain}.py", "content": content}
46
+
47
+
48
+ def generate_init(
49
+ model: dict[str, Any], config: dict[str, Any], env: Environment, project_root: Path
50
+ ) -> dict[str, Any] | None:
51
+ """Generate __init__.py with exports for all model files."""
52
+ output_dir = project_root / config["paths"]["database_models"]
53
+ domains = scan_model_files(output_dir)
54
+
55
+ if get_layout(config) == "per-entity":
56
+ existing_files = {d["file"] for d in domains}
57
+ for name in model.get("entities", {}).keys():
58
+ stem = snake_case(name)
59
+ if stem not in existing_files:
60
+ domains.append(
61
+ {
62
+ "name": stem,
63
+ "file": stem,
64
+ "section": None,
65
+ "entities": [name],
66
+ }
67
+ )
68
+ else:
69
+ current_domain = model.get("domain", "unknown")
70
+ domain_names = {d["name"] for d in domains}
71
+ if current_domain not in domain_names:
72
+ domains.append(
73
+ {
74
+ "name": current_domain,
75
+ "file": current_domain,
76
+ "section": model.get("section_header"),
77
+ "entities": list(model.get("entities", {}).keys()),
78
+ }
79
+ )
80
+
81
+ if not domains:
82
+ print(f" ℹ️ No model files found in {output_dir}")
83
+ return None
84
+
85
+ total_entities = sum(len(d["entities"]) for d in domains)
86
+
87
+ template = env.get_template("database/init.py.j2")
88
+ content = template.render(domains=domains, config=config)
89
+
90
+ return {
91
+ "path": output_dir / "__init__.py",
92
+ "content": content,
93
+ "mode": "write",
94
+ "domain_count": len(domains),
95
+ "entity_count": total_entities,
96
+ }
97
+
98
+
99
+ def generate_factories(
100
+ model: dict[str, Any],
101
+ config: dict[str, Any],
102
+ env: Environment,
103
+ project_root: Path,
104
+ model_path: Path | None = None,
105
+ constraints: dict[str, Any] | None = None,
106
+ ) -> dict[str, Any] | list[dict[str, Any]]:
107
+ """Generate FactoryBoy factories for test data generation.
108
+
109
+ Per-entity layout returns one factory file per entity; per-domain returns
110
+ a single combined file. ``sibling_entities`` (the full domain entity
111
+ list) is threaded into the template so cross-entity ``create_related``
112
+ blocks survive the per-entity slicing of ``model.entities``.
113
+
114
+ ``constraints`` (the flattened shared-constraint dict) is threaded into
115
+ the template so ``min_ref`` / ``max_ref`` bounds on financial/counter
116
+ fields resolve to literal values at generation time — the factory module
117
+ never imports the constraints module, so emitting the bare constant name
118
+ would ``NameError`` at ``create()`` time.
119
+ """
120
+ template = env.get_template("database/factory.py.j2")
121
+ factories_dir = project_root / config["paths"]["database_models"] / "factories"
122
+ sibling_entities = list(model.get("entities", {}).keys())
123
+ if constraints is None:
124
+ constraints = load_shared_constraints(model_path or project_root)
125
+
126
+ if get_layout(config) == "per-entity":
127
+ return [
128
+ {
129
+ "path": factories_dir / f"{snake_case(name)}.py",
130
+ "content": template.render(
131
+ model={**model, "entities": {name: entity}},
132
+ config=config,
133
+ sibling_entities=sibling_entities,
134
+ constraints=constraints,
135
+ ),
136
+ }
137
+ for name, entity in model.get("entities", {}).items()
138
+ ]
139
+
140
+ domain = model.get("domain", "models")
141
+ content = template.render(
142
+ model=model,
143
+ config=config,
144
+ sibling_entities=sibling_entities,
145
+ constraints=constraints,
146
+ )
147
+ return {"path": factories_dir / f"{domain}.py", "content": content}