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.
- model_generator/__init__.py +6 -0
- model_generator/generate.py +1030 -0
- model_generator/generators/__init__.py +38 -0
- model_generator/generators/api.py +287 -0
- model_generator/generators/constraints.py +176 -0
- model_generator/generators/database.py +147 -0
- model_generator/generators/enums.py +88 -0
- model_generator/generators/infrastructure.py +679 -0
- model_generator/generators/migrations.py +146 -0
- model_generator/py.typed +0 -0
- model_generator/schema/model.schema.json +758 -0
- model_generator/stacks/python-fastapi/config.yaml +403 -0
- model_generator/stacks/python-fastapi/templates/_shared/_base.j2 +26 -0
- model_generator/stacks/python-fastapi/templates/_shared/_entity.j2 +48 -0
- model_generator/stacks/python-fastapi/templates/_shared/_examples.j2 +50 -0
- model_generator/stacks/python-fastapi/templates/_shared/_fields.j2 +48 -0
- model_generator/stacks/python-fastapi/templates/_shared/_tests.j2 +143 -0
- model_generator/stacks/python-fastapi/templates/api/init.py.j2 +55 -0
- model_generator/stacks/python-fastapi/templates/api/pagination.py.j2 +79 -0
- model_generator/stacks/python-fastapi/templates/api/request.py.j2 +448 -0
- model_generator/stacks/python-fastapi/templates/api/response.py.j2 +222 -0
- model_generator/stacks/python-fastapi/templates/api/route.py.j2 +507 -0
- model_generator/stacks/python-fastapi/templates/database/constraints.py.j2 +439 -0
- model_generator/stacks/python-fastapi/templates/database/enums.py.j2 +55 -0
- model_generator/stacks/python-fastapi/templates/database/factory.py.j2 +265 -0
- model_generator/stacks/python-fastapi/templates/database/init.py.j2 +37 -0
- model_generator/stacks/python-fastapi/templates/database/model.py.j2 +476 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/auth_router.py.j2 +434 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/base.py.j2 +16 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/csrf.py.j2 +121 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/database_init.py.j2 +12 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/encrypted_bytes.py.j2 +62 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/engine.py.j2 +51 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/errors.py.j2 +74 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/gitignore.j2 +48 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/main.py.j2 +94 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/pyproject.toml.j2 +92 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/rate_limit.py.j2 +41 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/types.py.j2 +94 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/utils.py.j2 +50 -0
- model_generator/stacks/python-fastapi/templates/infrastructure/validators.py.j2 +126 -0
- model_generator/stacks/python-fastapi/templates/migrations/env.py.j2 +125 -0
- model_generator/stacks/python-fastapi/templates/migrations/ini.j2 +109 -0
- model_generator/stacks/python-fastapi/templates/migrations/script.py.mako.j2 +35 -0
- model_generator/stacks/python-fastapi/templates/tests/conftest_root.py.j2 +122 -0
- model_generator/stacks/python-fastapi/templates/tests/contract.py.j2 +1860 -0
- model_generator/utils/__init__.py +31 -0
- model_generator/utils/conftest_generator.py +683 -0
- model_generator/utils/constants.py +6 -0
- model_generator/utils/loaders.py +292 -0
- model_generator/utils/parser.py +129 -0
- model_generator/utils/quality.py +43 -0
- model_generator/utils/templates.py +128 -0
- model_generator/validate.py +219 -0
- model_generator/wizard/__init__.py +10 -0
- model_generator/wizard/actions/__init__.py +1 -0
- model_generator/wizard/actions/clean.py +55 -0
- model_generator/wizard/actions/generate.py +166 -0
- model_generator/wizard/actions/project_setup.py +142 -0
- model_generator/wizard/actions/test_runner.py +60 -0
- model_generator/wizard/menu.py +43 -0
- model_generator/wizard/prompts.py +80 -0
- model_generator_kit-0.1.0.dist-info/METADATA +143 -0
- model_generator_kit-0.1.0.dist-info/RECORD +68 -0
- model_generator_kit-0.1.0.dist-info/WHEEL +5 -0
- model_generator_kit-0.1.0.dist-info/entry_points.txt +3 -0
- model_generator_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- model_generator_kit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Model Code Generator.
|
|
4
|
+
|
|
5
|
+
Generates code files from multi-entity model JSON definitions using Jinja2 templates.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python generate.py <model.json> [--target TARGET] [--diff] [--dry-run]
|
|
9
|
+
python generate.py <model-directory> [--target TARGET]
|
|
10
|
+
python generate.py models/users.model.json --target database
|
|
11
|
+
python generate.py models/ --target all
|
|
12
|
+
|
|
13
|
+
TDD Generation Order (when --target all):
|
|
14
|
+
1. database - SQLAlchemy models (source of truth)
|
|
15
|
+
2. api-models - Pydantic request/response models
|
|
16
|
+
3. api-tests - Contract tests (RED phase)
|
|
17
|
+
4. api-routes - FastAPI routes (GREEN phase)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from .generators import (
|
|
28
|
+
generate_api_init,
|
|
29
|
+
generate_api_models,
|
|
30
|
+
generate_api_pagination,
|
|
31
|
+
generate_api_routes,
|
|
32
|
+
generate_api_tests,
|
|
33
|
+
generate_constraints,
|
|
34
|
+
generate_database_model,
|
|
35
|
+
generate_enums,
|
|
36
|
+
generate_factories,
|
|
37
|
+
generate_infrastructure,
|
|
38
|
+
generate_init,
|
|
39
|
+
generate_migration_autogen,
|
|
40
|
+
generate_migration_init,
|
|
41
|
+
)
|
|
42
|
+
from .utils import (
|
|
43
|
+
get_layout,
|
|
44
|
+
get_template_env,
|
|
45
|
+
load_config,
|
|
46
|
+
load_shared_constraints,
|
|
47
|
+
load_shared_enums,
|
|
48
|
+
run_quality_tools,
|
|
49
|
+
)
|
|
50
|
+
from .utils import load_model as load_model
|
|
51
|
+
from .utils.conftest_generator import generate_conftest_content
|
|
52
|
+
from .utils.templates import path_to_import, snake_case
|
|
53
|
+
|
|
54
|
+
# TDD-ordered generation targets
|
|
55
|
+
INFRASTRUCTURE_TARGETS = [
|
|
56
|
+
"base",
|
|
57
|
+
"engine",
|
|
58
|
+
"main",
|
|
59
|
+
"test-conftest-root",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
DOMAIN_TARGETS = [
|
|
63
|
+
"enums",
|
|
64
|
+
"constraints",
|
|
65
|
+
"init",
|
|
66
|
+
"database",
|
|
67
|
+
"factories",
|
|
68
|
+
"api-models",
|
|
69
|
+
"api-init",
|
|
70
|
+
"api-pagination",
|
|
71
|
+
"api-tests",
|
|
72
|
+
"api-tests-config",
|
|
73
|
+
"api-routes",
|
|
74
|
+
"migration-init",
|
|
75
|
+
"migration-autogen",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
TARGETS = INFRASTRUCTURE_TARGETS + DOMAIN_TARGETS + ["infrastructure", "all"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Generator dispatch table
|
|
82
|
+
_GeneratorFn = Callable[
|
|
83
|
+
[dict[str, Any], dict[str, Any], Any, Path, Path],
|
|
84
|
+
dict[str, Any] | list[dict[str, Any]] | None,
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
GENERATORS: dict[str, _GeneratorFn] = {
|
|
88
|
+
"enums": lambda m, c, e, p, mp: generate_enums(m, c, e, p, mp),
|
|
89
|
+
"constraints": lambda m, c, e, p, mp: generate_constraints(m, c, e, p, mp),
|
|
90
|
+
"init": lambda m, c, e, p, mp: generate_init(m, c, e, p),
|
|
91
|
+
"database": lambda m, c, e, p, mp: generate_database_model(m, c, e, p),
|
|
92
|
+
"factories": lambda m, c, e, p, mp: generate_factories(m, c, e, p, mp),
|
|
93
|
+
"api-models": lambda m, c, e, p, mp: generate_api_models(m, c, e, p, mp),
|
|
94
|
+
"api-init": lambda m, c, e, p, mp: generate_api_init(m, c, e, p),
|
|
95
|
+
"api-pagination": lambda m, c, e, p, mp: generate_api_pagination(m, c, e, p),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cleanup_generated(
|
|
100
|
+
project_root: Path, scope: str = "selective", dry_run: bool = False
|
|
101
|
+
) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Delete generated code files.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
project_root: Project root directory
|
|
107
|
+
scope: "selective" (generated files only) or "full" (entire directories)
|
|
108
|
+
dry_run: Show what would be deleted without deleting
|
|
109
|
+
"""
|
|
110
|
+
config = load_config()
|
|
111
|
+
paths = config.get("paths", {})
|
|
112
|
+
|
|
113
|
+
if scope == "full":
|
|
114
|
+
_cleanup_full(project_root, paths, dry_run)
|
|
115
|
+
else:
|
|
116
|
+
_cleanup_selective(project_root, paths, dry_run)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _cleanup_full(project_root: Path, paths: dict[str, Any], dry_run: bool) -> None:
|
|
120
|
+
"""Delete entire source directories and generated files."""
|
|
121
|
+
dirs_to_delete = set()
|
|
122
|
+
files_to_delete = set()
|
|
123
|
+
|
|
124
|
+
# Generated source directories
|
|
125
|
+
for key in ["database_models", "factories", "api_models", "api_routes"]:
|
|
126
|
+
if key in paths:
|
|
127
|
+
path_parts = paths[key].split("/")
|
|
128
|
+
if path_parts:
|
|
129
|
+
dirs_to_delete.add(project_root / path_parts[0])
|
|
130
|
+
|
|
131
|
+
# Test directory
|
|
132
|
+
api_tests = paths.get("api_tests", "tests/contract/api")
|
|
133
|
+
test_root = api_tests.split("/")[0]
|
|
134
|
+
dirs_to_delete.add(project_root / test_root)
|
|
135
|
+
|
|
136
|
+
# Migrations directory
|
|
137
|
+
dirs_to_delete.add(project_root / paths.get("migrations", "alembic"))
|
|
138
|
+
|
|
139
|
+
# Cache directories
|
|
140
|
+
cache_dirs = [
|
|
141
|
+
".pytest_cache",
|
|
142
|
+
".mypy_cache",
|
|
143
|
+
".ruff_cache",
|
|
144
|
+
"__pycache__",
|
|
145
|
+
".venv",
|
|
146
|
+
"venv",
|
|
147
|
+
]
|
|
148
|
+
for cache_dir in cache_dirs:
|
|
149
|
+
cache_path = project_root / cache_dir
|
|
150
|
+
if cache_path.exists():
|
|
151
|
+
dirs_to_delete.add(cache_path)
|
|
152
|
+
|
|
153
|
+
# Find all __pycache__ recursively in project source/tests
|
|
154
|
+
for src_dir in [
|
|
155
|
+
project_root / "backend",
|
|
156
|
+
project_root / "tests",
|
|
157
|
+
project_root / "src",
|
|
158
|
+
]:
|
|
159
|
+
if src_dir.exists():
|
|
160
|
+
for pycache in src_dir.rglob("__pycache__"):
|
|
161
|
+
dirs_to_delete.add(pycache)
|
|
162
|
+
|
|
163
|
+
# Generated files
|
|
164
|
+
alembic_ini = project_root / "alembic.ini"
|
|
165
|
+
if alembic_ini.exists():
|
|
166
|
+
files_to_delete.add(alembic_ini)
|
|
167
|
+
|
|
168
|
+
print("🗑️ Full cleanup mode:")
|
|
169
|
+
|
|
170
|
+
# Delete files first
|
|
171
|
+
for file_path in sorted(files_to_delete):
|
|
172
|
+
print(f" {'Would delete' if dry_run else 'Deleting'}: {file_path}")
|
|
173
|
+
if not dry_run:
|
|
174
|
+
file_path.unlink()
|
|
175
|
+
|
|
176
|
+
# Delete directories
|
|
177
|
+
for dir_path in sorted(dirs_to_delete):
|
|
178
|
+
if dir_path.exists():
|
|
179
|
+
print(f" {'Would delete' if dry_run else 'Deleting'}: {dir_path}")
|
|
180
|
+
if not dry_run:
|
|
181
|
+
shutil.rmtree(dir_path)
|
|
182
|
+
|
|
183
|
+
if not dry_run:
|
|
184
|
+
print("✅ Cleanup complete")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _cleanup_selective(
|
|
188
|
+
project_root: Path, paths: dict[str, Any], dry_run: bool
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Delete only generated files, not entire directories."""
|
|
191
|
+
files_to_delete: list[Path] = []
|
|
192
|
+
dirs_to_delete: list[Path] = []
|
|
193
|
+
|
|
194
|
+
patterns = []
|
|
195
|
+
for key in ["database_models", "factories", "api_models", "api_routes"]:
|
|
196
|
+
if key in paths:
|
|
197
|
+
patterns.append(f"{paths[key]}/*.py")
|
|
198
|
+
# Also include __init__.py in these directories
|
|
199
|
+
patterns.append(f"{paths[key]}/__init__.py")
|
|
200
|
+
|
|
201
|
+
api_tests = paths.get("api_tests", "tests/contract/api")
|
|
202
|
+
patterns.append(f"{api_tests}/*.py")
|
|
203
|
+
patterns.append(f"{api_tests}/__init__.py")
|
|
204
|
+
|
|
205
|
+
# Add parent __init__.py for tests
|
|
206
|
+
test_dir = api_tests
|
|
207
|
+
while "/" in test_dir:
|
|
208
|
+
test_dir = str(Path(test_dir).parent)
|
|
209
|
+
patterns.append(f"{test_dir}/__init__.py")
|
|
210
|
+
|
|
211
|
+
migrations = paths.get("migrations", "alembic")
|
|
212
|
+
patterns.append(f"{migrations}/versions/*.py")
|
|
213
|
+
# Alembic infra files
|
|
214
|
+
patterns.append(f"{migrations}/env.py")
|
|
215
|
+
patterns.append(f"{migrations}/script.py.mako")
|
|
216
|
+
patterns.append(f"{migrations}/README.md")
|
|
217
|
+
patterns.append(f"{migrations}/versions/.gitkeep")
|
|
218
|
+
|
|
219
|
+
# All explicit file paths in config
|
|
220
|
+
for key, path in paths.items():
|
|
221
|
+
if isinstance(path, str) and (path.endswith(".py") or path.endswith(".ini")):
|
|
222
|
+
files_to_delete.append(project_root / path)
|
|
223
|
+
|
|
224
|
+
# Derived infrastructure files
|
|
225
|
+
if "api_models" in paths:
|
|
226
|
+
api_dir = Path(paths["api_models"]).parent
|
|
227
|
+
files_to_delete.append(project_root / api_dir / "utils.py")
|
|
228
|
+
files_to_delete.append(project_root / api_dir / "__init__.py")
|
|
229
|
+
|
|
230
|
+
if "database_models" in paths:
|
|
231
|
+
db_dir = Path(paths["database_models"]).parent
|
|
232
|
+
files_to_delete.append(project_root / db_dir / "types.py")
|
|
233
|
+
files_to_delete.append(project_root / db_dir / "__init__.py")
|
|
234
|
+
|
|
235
|
+
if "main" in paths:
|
|
236
|
+
src_dir = Path(paths["main"]).parent
|
|
237
|
+
files_to_delete.append(project_root / src_dir / "__init__.py")
|
|
238
|
+
|
|
239
|
+
# Also include alembic.ini
|
|
240
|
+
alembic_ini = project_root / "alembic.ini"
|
|
241
|
+
if alembic_ini.exists():
|
|
242
|
+
files_to_delete.append(alembic_ini)
|
|
243
|
+
|
|
244
|
+
for pattern in patterns:
|
|
245
|
+
files_to_delete.extend(project_root.glob(pattern))
|
|
246
|
+
|
|
247
|
+
# Find __pycache__ in generated directories (recursive) and their parents
|
|
248
|
+
# (non-recursive — parents may contain user-written code whose __pycache__
|
|
249
|
+
# must not be touched).
|
|
250
|
+
generated_dirs: set[Path] = set()
|
|
251
|
+
parent_dirs: set[Path] = set()
|
|
252
|
+
for key in [
|
|
253
|
+
"database_models",
|
|
254
|
+
"factories",
|
|
255
|
+
"api_models",
|
|
256
|
+
"api_routes",
|
|
257
|
+
"api_tests",
|
|
258
|
+
"migrations",
|
|
259
|
+
]:
|
|
260
|
+
if key in paths:
|
|
261
|
+
path = project_root / paths[key]
|
|
262
|
+
if path.exists():
|
|
263
|
+
generated_dirs.add(path)
|
|
264
|
+
if key != "migrations":
|
|
265
|
+
parent_dirs.add(path.parent)
|
|
266
|
+
|
|
267
|
+
for d in generated_dirs:
|
|
268
|
+
if d.is_dir():
|
|
269
|
+
for pycache in d.rglob("__pycache__"):
|
|
270
|
+
dirs_to_delete.append(pycache)
|
|
271
|
+
|
|
272
|
+
for d in parent_dirs:
|
|
273
|
+
if d.is_dir():
|
|
274
|
+
for pycache in d.glob("__pycache__"):
|
|
275
|
+
dirs_to_delete.append(pycache)
|
|
276
|
+
|
|
277
|
+
print("🗑️ Selective cleanup mode:")
|
|
278
|
+
deleted_count = 0
|
|
279
|
+
# Use set to avoid duplicates, but filter for existence
|
|
280
|
+
for file_path in sorted({f for f in files_to_delete if f.exists()}):
|
|
281
|
+
print(f" {'Would delete' if dry_run else 'Deleting'}: {file_path}")
|
|
282
|
+
if not dry_run:
|
|
283
|
+
if file_path.is_file():
|
|
284
|
+
file_path.unlink()
|
|
285
|
+
deleted_count += 1
|
|
286
|
+
|
|
287
|
+
for dir_path in sorted(set(dirs_to_delete)):
|
|
288
|
+
if dir_path.exists() and dir_path.is_dir():
|
|
289
|
+
print(f" {'Would delete' if dry_run else 'Deleting'}: {dir_path}")
|
|
290
|
+
if not dry_run:
|
|
291
|
+
shutil.rmtree(dir_path)
|
|
292
|
+
|
|
293
|
+
if not dry_run:
|
|
294
|
+
print(f"✅ Cleanup complete ({deleted_count} files)")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def generate_conftest(
|
|
298
|
+
model: dict[str, Any],
|
|
299
|
+
config: dict[str, Any],
|
|
300
|
+
env: Any,
|
|
301
|
+
project_root: Path,
|
|
302
|
+
model_path: Path,
|
|
303
|
+
) -> dict[str, Any] | None:
|
|
304
|
+
"""Generate conftest.py with fixtures for all domains."""
|
|
305
|
+
if model_path.is_file():
|
|
306
|
+
models_dir = model_path.parent
|
|
307
|
+
else:
|
|
308
|
+
models_dir = model_path
|
|
309
|
+
|
|
310
|
+
auth_strategy = config.get("auth", {}).get("strategy")
|
|
311
|
+
rate_limiter_import = _compute_rate_limiter_import(config)
|
|
312
|
+
content, count = generate_conftest_content(
|
|
313
|
+
models_dir,
|
|
314
|
+
auth_strategy=auth_strategy,
|
|
315
|
+
rate_limiter_import=rate_limiter_import,
|
|
316
|
+
)
|
|
317
|
+
output_dir = project_root / config["paths"]["api_tests"]
|
|
318
|
+
output_file = output_dir / "conftest.py"
|
|
319
|
+
|
|
320
|
+
return {"path": output_file, "content": content, "mode": "write"}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _compute_rate_limiter_import(config: dict[str, Any]) -> str | None:
|
|
324
|
+
"""Return the import path to the auth rate_limit module, or None.
|
|
325
|
+
|
|
326
|
+
Mirrors the import-path logic in ``generators/infrastructure.py``: emits
|
|
327
|
+
a value only when ``auth.strategy`` is set and rate limiting is enabled
|
|
328
|
+
(the slowapi default-on behavior).
|
|
329
|
+
"""
|
|
330
|
+
auth = config.get("auth") or {}
|
|
331
|
+
if not auth.get("strategy"):
|
|
332
|
+
return None
|
|
333
|
+
rate_limit = auth.get("rate_limit") or {}
|
|
334
|
+
if rate_limit.get("enabled") is False:
|
|
335
|
+
return None
|
|
336
|
+
auth_path = auth.get("path", "backend/src/auth/router.py")
|
|
337
|
+
rate_limit_module_path = str(Path(auth_path).parent / "rate_limit")
|
|
338
|
+
python_root = config.get("python_root", "")
|
|
339
|
+
return path_to_import(rate_limit_module_path, python_root=python_root)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _compute_auth_extra(config: dict[str, Any]) -> list[str]:
|
|
343
|
+
"""Runtime deps the auth scaffolding pulls in. Empty when auth is off.
|
|
344
|
+
|
|
345
|
+
The auth router uses bcrypt for password hashing and itsdangerous for
|
|
346
|
+
cookie/token signing. email-validator backs Pydantic's EmailStr. slowapi
|
|
347
|
+
is added when rate limiting is enabled (default-on); redis is added when
|
|
348
|
+
its storage backend is selected.
|
|
349
|
+
"""
|
|
350
|
+
auth = config.get("auth") or {}
|
|
351
|
+
if not auth.get("strategy"):
|
|
352
|
+
return []
|
|
353
|
+
extra = ["bcrypt>=4.0.0", "itsdangerous>=2.0", "email-validator>=2.0"]
|
|
354
|
+
rate_limit = auth.get("rate_limit") or {}
|
|
355
|
+
if rate_limit.get("enabled") is not False:
|
|
356
|
+
extra.append("slowapi>=0.1.9")
|
|
357
|
+
if rate_limit.get("backend") == "redis":
|
|
358
|
+
extra.append("redis>=4.0")
|
|
359
|
+
return extra
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _has_encrypted_binary_field(models: list[dict[str, Any]]) -> bool:
|
|
363
|
+
"""True when any loaded model has a ``binary`` field with an ``encrypt`` block.
|
|
364
|
+
|
|
365
|
+
Mirrors the ``ns.has_encrypted_binary`` template flag in ``model.py.j2``
|
|
366
|
+
and gates the project-wide emission of ``encrypted_bytes.py``.
|
|
367
|
+
"""
|
|
368
|
+
return any(
|
|
369
|
+
field.get("type") == "binary" and "encrypt" in field
|
|
370
|
+
for model in models
|
|
371
|
+
for entity in model.get("entities", {}).values()
|
|
372
|
+
for field in entity.get("fields", {}).values()
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def generate(
|
|
377
|
+
model_path: Path,
|
|
378
|
+
target: str = "all",
|
|
379
|
+
diff: bool = False,
|
|
380
|
+
dry_run: bool = False,
|
|
381
|
+
stack: str = "python-fastapi",
|
|
382
|
+
no_root_files: bool = False,
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Generate code from model definition."""
|
|
385
|
+
project_root = _find_project_root(model_path)
|
|
386
|
+
_validate_project_root(project_root)
|
|
387
|
+
|
|
388
|
+
model = load_model(model_path)
|
|
389
|
+
config = load_config(stack)
|
|
390
|
+
_validate_auth_config(model, config)
|
|
391
|
+
_validate_generation_config(config)
|
|
392
|
+
_validate_paths_base(config)
|
|
393
|
+
_validate_composite_foreign_keys(model)
|
|
394
|
+
env = get_template_env(stack, config)
|
|
395
|
+
|
|
396
|
+
domain = model.get("domain", "unknown")
|
|
397
|
+
entity_count = len(model.get("entities", {}))
|
|
398
|
+
|
|
399
|
+
print(f"\n🔧 Generating code for domain: {domain} ({entity_count} entities)")
|
|
400
|
+
print(f" Target: {target}")
|
|
401
|
+
print(f" Stack: {stack}")
|
|
402
|
+
|
|
403
|
+
outputs = []
|
|
404
|
+
targets_to_generate = TARGETS[:-1] if target == "all" else [target]
|
|
405
|
+
|
|
406
|
+
# Pre-load shared data to avoid duplicate loading
|
|
407
|
+
enums = load_shared_enums(model_path)
|
|
408
|
+
constraints = load_shared_constraints(model_path)
|
|
409
|
+
|
|
410
|
+
for t in targets_to_generate:
|
|
411
|
+
result = _generate_target(
|
|
412
|
+
t,
|
|
413
|
+
model,
|
|
414
|
+
config,
|
|
415
|
+
env,
|
|
416
|
+
project_root,
|
|
417
|
+
model_path,
|
|
418
|
+
enums,
|
|
419
|
+
constraints,
|
|
420
|
+
no_root_files=no_root_files,
|
|
421
|
+
)
|
|
422
|
+
if result is None:
|
|
423
|
+
continue
|
|
424
|
+
if isinstance(result, list):
|
|
425
|
+
outputs.extend(result)
|
|
426
|
+
elif isinstance(result, dict) and "instructions" in result:
|
|
427
|
+
print(result["instructions"])
|
|
428
|
+
else:
|
|
429
|
+
outputs.append(result)
|
|
430
|
+
|
|
431
|
+
generated_files = _process_outputs(outputs, diff, dry_run)
|
|
432
|
+
|
|
433
|
+
if generated_files and not dry_run and not diff:
|
|
434
|
+
run_quality_tools(config, project_root, generated_files)
|
|
435
|
+
|
|
436
|
+
if not diff and not dry_run:
|
|
437
|
+
print(f"\n✅ Generated {len(generated_files)} file(s)")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _find_project_root(model_path: Path) -> Path:
|
|
441
|
+
"""Find project root by looking for .model-generator.yaml."""
|
|
442
|
+
project_root = Path.cwd()
|
|
443
|
+
if not (project_root / ".model-generator.yaml").exists():
|
|
444
|
+
parent = project_root.parent
|
|
445
|
+
if (parent / ".model-generator.yaml").exists():
|
|
446
|
+
project_root = parent
|
|
447
|
+
else:
|
|
448
|
+
project_root = model_path.parent.parent
|
|
449
|
+
if model_path.parent.name == "models":
|
|
450
|
+
project_root = model_path.parent.parent
|
|
451
|
+
return project_root
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# The directory that contains model-generator's own source code.
|
|
455
|
+
# Used to guard against accidentally generating into the tool itself.
|
|
456
|
+
_GENERATOR_OWN_DIR = Path(__file__).parent.parent.parent.resolve()
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _validate_project_root(project_root: Path) -> None:
|
|
460
|
+
"""
|
|
461
|
+
Abort if project_root is unsafe to generate into.
|
|
462
|
+
|
|
463
|
+
Raises SystemExit when:
|
|
464
|
+
- No .model-generator.yaml exists in project_root (not a generated project)
|
|
465
|
+
- project_root is model-generator's own source directory
|
|
466
|
+
"""
|
|
467
|
+
resolved = project_root.resolve()
|
|
468
|
+
|
|
469
|
+
if resolved == _GENERATOR_OWN_DIR:
|
|
470
|
+
print(
|
|
471
|
+
f"Error: Refusing to generate into model-generator's own directory "
|
|
472
|
+
f"({resolved}).\n"
|
|
473
|
+
"Run model-gen from inside your target project, or pass the models "
|
|
474
|
+
"directory as a path relative to that project."
|
|
475
|
+
)
|
|
476
|
+
sys.exit(1)
|
|
477
|
+
|
|
478
|
+
if not (project_root / ".model-generator.yaml").exists():
|
|
479
|
+
print(
|
|
480
|
+
f"Error: No .model-generator.yaml found in {project_root}.\n"
|
|
481
|
+
"Create a .model-generator.yaml in your project root, or run model-gen "
|
|
482
|
+
"from inside the project directory."
|
|
483
|
+
)
|
|
484
|
+
sys.exit(1)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _validate_auth_config(model: dict[str, Any], config: dict[str, Any]) -> None:
|
|
488
|
+
"""Abort if any entity declares api.scope without auth.dependency_path in config."""
|
|
489
|
+
scoped = [
|
|
490
|
+
name
|
|
491
|
+
for name, entity in model.get("entities", {}).items()
|
|
492
|
+
if entity.get("api", {}).get("scope")
|
|
493
|
+
]
|
|
494
|
+
if not scoped:
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
auth_dep = config.get("auth", {}).get("dependency_path")
|
|
498
|
+
if not auth_dep:
|
|
499
|
+
names = ", ".join(scoped)
|
|
500
|
+
print(
|
|
501
|
+
f"Error: Entities ({names}) declare api.scope but "
|
|
502
|
+
"auth.dependency_path is not set in .model-generator.yaml.\n\n"
|
|
503
|
+
"Add this to your .model-generator.yaml:\n\n"
|
|
504
|
+
" auth:\n"
|
|
505
|
+
' dependency_path: "path.to.your.get_current_user"\n\n'
|
|
506
|
+
"The generator will import this function and inject it via "
|
|
507
|
+
"FastAPI's Depends() in scoped endpoints."
|
|
508
|
+
)
|
|
509
|
+
sys.exit(1)
|
|
510
|
+
|
|
511
|
+
if "." not in auth_dep:
|
|
512
|
+
print(
|
|
513
|
+
f'Error: auth.dependency_path "{auth_dep}" must be a dotted path '
|
|
514
|
+
'like "module.submodule.get_current_user".\n'
|
|
515
|
+
"The segment before the last dot is the import module; the segment "
|
|
516
|
+
"after is the callable."
|
|
517
|
+
)
|
|
518
|
+
sys.exit(1)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _validate_generation_config(config: dict[str, Any]) -> None:
|
|
522
|
+
"""Abort if generation.layout has an unknown value."""
|
|
523
|
+
valid = {"per-entity", "per-domain"}
|
|
524
|
+
layout = get_layout(config)
|
|
525
|
+
if layout not in valid:
|
|
526
|
+
choices = ", ".join(repr(v) for v in sorted(valid))
|
|
527
|
+
print(
|
|
528
|
+
f"Error: generation.layout must be one of [{choices}], "
|
|
529
|
+
f'got "{layout}".\n\n'
|
|
530
|
+
"Set in .model-generator.yaml:\n\n"
|
|
531
|
+
" generation:\n"
|
|
532
|
+
' layout: "per-entity" # default; one file per entity\n'
|
|
533
|
+
" # or\n"
|
|
534
|
+
" generation:\n"
|
|
535
|
+
' layout: "per-domain" # legacy; one file per domain'
|
|
536
|
+
)
|
|
537
|
+
sys.exit(1)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _validate_paths_base(config: dict[str, Any]) -> None:
|
|
541
|
+
"""Abort if paths.base is not inside paths.database_models (or misnamed).
|
|
542
|
+
|
|
543
|
+
Generated database model files emit ``from .base import Base`` (relative),
|
|
544
|
+
so the base module must live inside paths.database_models AND be named
|
|
545
|
+
``base.py``. A mismatch is silent at generation time but raises
|
|
546
|
+
``ModuleNotFoundError`` at import or test-collection time.
|
|
547
|
+
"""
|
|
548
|
+
paths = config.get("paths", {})
|
|
549
|
+
db_models_str = paths.get("database_models", "backend/src/database/models")
|
|
550
|
+
base_str = paths.get("base", f"{db_models_str}/base.py")
|
|
551
|
+
|
|
552
|
+
base_path = Path(base_str)
|
|
553
|
+
if base_path.name != "base.py":
|
|
554
|
+
print(
|
|
555
|
+
f'Error: paths.base filename must be "base.py" '
|
|
556
|
+
f'(got "{base_path.name}" from "{base_str}").\n\n'
|
|
557
|
+
"Generated model files import the base module with a hardcoded "
|
|
558
|
+
"relative 'from .base import Base' statement, so the filename "
|
|
559
|
+
"is fixed.\n\n"
|
|
560
|
+
"Fix in .model-generator.yaml:\n\n"
|
|
561
|
+
" paths:\n"
|
|
562
|
+
f" base: {base_path.parent}/base.py"
|
|
563
|
+
)
|
|
564
|
+
sys.exit(1)
|
|
565
|
+
|
|
566
|
+
if base_path.parent != Path(db_models_str):
|
|
567
|
+
print(
|
|
568
|
+
f'Error: paths.base ("{base_str}") must live inside '
|
|
569
|
+
f'paths.database_models ("{db_models_str}"), '
|
|
570
|
+
f'but its parent is "{base_path.parent}".\n\n'
|
|
571
|
+
"Generated model files import the base module with a relative "
|
|
572
|
+
"'from .base import Base' statement, so paths.base must be a "
|
|
573
|
+
"child of paths.database_models on disk.\n\n"
|
|
574
|
+
"Fix in .model-generator.yaml:\n\n"
|
|
575
|
+
" paths:\n"
|
|
576
|
+
f" database_models: {db_models_str}\n"
|
|
577
|
+
f" base: {db_models_str}/base.py"
|
|
578
|
+
)
|
|
579
|
+
sys.exit(1)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _validate_composite_foreign_keys(model: dict[str, Any]) -> None:
|
|
583
|
+
"""Abort if any entity declares a composite foreign_key with invalid structure.
|
|
584
|
+
|
|
585
|
+
Per composite FK, checks:
|
|
586
|
+
- len(fk.fields) == len(fk.references_columns)
|
|
587
|
+
- All names in fk.fields exist in entity.fields
|
|
588
|
+
- No fk.fields member is typed "reference" (mutex with single-column FK)
|
|
589
|
+
- fk.references_table matches an entity table in this model
|
|
590
|
+
|
|
591
|
+
Cross-model composite FKs (target entity in another model file) are
|
|
592
|
+
rejected for v1; the underlying template emission works mechanically,
|
|
593
|
+
but cross-model validation is deferred.
|
|
594
|
+
"""
|
|
595
|
+
entities = model.get("entities", {}) or {}
|
|
596
|
+
known_tables = {entity.get("table") for entity in entities.values()}
|
|
597
|
+
|
|
598
|
+
errors: list[str] = []
|
|
599
|
+
for entity_name, entity in entities.items():
|
|
600
|
+
entity_fields = entity.get("fields", {}) or {}
|
|
601
|
+
for fk_idx, fk in enumerate(entity.get("foreign_keys", []) or []):
|
|
602
|
+
label = f"{entity_name}.foreign_keys[{fk_idx}]"
|
|
603
|
+
fields = fk.get("fields") or []
|
|
604
|
+
ref_cols = fk.get("references_columns") or []
|
|
605
|
+
ref_table = fk.get("references_table")
|
|
606
|
+
|
|
607
|
+
if len(fields) != len(ref_cols):
|
|
608
|
+
errors.append(
|
|
609
|
+
f" - {label}: fields has {len(fields)} entries but "
|
|
610
|
+
f"references_columns has {len(ref_cols)} (must match)"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
for f in fields:
|
|
614
|
+
if f not in entity_fields:
|
|
615
|
+
errors.append(
|
|
616
|
+
f' - {label}: field "{f}" not declared in {entity_name}.fields'
|
|
617
|
+
)
|
|
618
|
+
elif entity_fields[f].get("type") == "reference":
|
|
619
|
+
errors.append(
|
|
620
|
+
f' - {label}: field "{f}" has type "reference" '
|
|
621
|
+
"(mutex with composite FK — declare as the underlying "
|
|
622
|
+
'type like "uuid" instead)'
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
if ref_table not in known_tables:
|
|
626
|
+
known = ", ".join(sorted(t for t in known_tables if t))
|
|
627
|
+
errors.append(
|
|
628
|
+
f' - {label}: references_table "{ref_table}" not found '
|
|
629
|
+
f"in this model (known tables: {known})"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
if errors:
|
|
633
|
+
joined = "\n".join(errors)
|
|
634
|
+
print(
|
|
635
|
+
"Error: Invalid composite foreign_keys declarations:\n\n"
|
|
636
|
+
f"{joined}\n\n"
|
|
637
|
+
"Composite FKs require:\n"
|
|
638
|
+
" - All listed fields declared in entity.fields\n"
|
|
639
|
+
' - Fields typed as their underlying type (not "reference")\n'
|
|
640
|
+
" - references_columns count equal to fields count\n"
|
|
641
|
+
" - references_table matching an entity table in this model"
|
|
642
|
+
)
|
|
643
|
+
sys.exit(1)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _validate_auth_strategy(
|
|
647
|
+
models: list[dict[str, Any]], config: dict[str, Any]
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Abort if auth.strategy is set but its prerequisites are missing.
|
|
650
|
+
|
|
651
|
+
Cross-model validation: takes the full list of loaded models so it can
|
|
652
|
+
check that *some* spec contains a User entity with a password_hash field.
|
|
653
|
+
Called once from main() after the aggregation loop, not per-model.
|
|
654
|
+
"""
|
|
655
|
+
auth = config.get("auth") or {}
|
|
656
|
+
strategy = auth.get("strategy")
|
|
657
|
+
if not strategy:
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
valid_strategies = {"bcrypt-session"}
|
|
661
|
+
if strategy not in valid_strategies:
|
|
662
|
+
choices = ", ".join(repr(v) for v in sorted(valid_strategies))
|
|
663
|
+
print(
|
|
664
|
+
f'Error: auth.strategy "{strategy}" is not supported.\n'
|
|
665
|
+
f"Allowed strategies: [{choices}].\n\n"
|
|
666
|
+
"Set in .model-generator.yaml:\n\n"
|
|
667
|
+
" auth:\n"
|
|
668
|
+
' strategy: "bcrypt-session"\n'
|
|
669
|
+
' pepper_env: "APP_PASSWORD_PEPPER"'
|
|
670
|
+
)
|
|
671
|
+
sys.exit(1)
|
|
672
|
+
|
|
673
|
+
pepper_env = auth.get("pepper_env")
|
|
674
|
+
if not isinstance(pepper_env, str) or not pepper_env.strip():
|
|
675
|
+
print(
|
|
676
|
+
f'Error: auth.strategy "{strategy}" requires auth.pepper_env to '
|
|
677
|
+
"name a non-empty environment variable.\n\n"
|
|
678
|
+
"Set in .model-generator.yaml:\n\n"
|
|
679
|
+
" auth:\n"
|
|
680
|
+
f' strategy: "{strategy}"\n'
|
|
681
|
+
' pepper_env: "APP_PASSWORD_PEPPER"'
|
|
682
|
+
)
|
|
683
|
+
sys.exit(1)
|
|
684
|
+
|
|
685
|
+
layout = get_layout(config)
|
|
686
|
+
if layout != "per-entity":
|
|
687
|
+
print(
|
|
688
|
+
f'Error: auth.strategy "{strategy}" currently requires '
|
|
689
|
+
f'generation.layout: per-entity (got "{layout}").\n\n'
|
|
690
|
+
"Set in .model-generator.yaml:\n\n"
|
|
691
|
+
" generation:\n"
|
|
692
|
+
' layout: "per-entity"\n\n'
|
|
693
|
+
"Per-domain auth scaffolding may be added in a future version."
|
|
694
|
+
)
|
|
695
|
+
sys.exit(1)
|
|
696
|
+
|
|
697
|
+
user_entity = None
|
|
698
|
+
for model in models:
|
|
699
|
+
entities = model.get("entities", {}) or {}
|
|
700
|
+
if "User" in entities:
|
|
701
|
+
user_entity = entities["User"]
|
|
702
|
+
break
|
|
703
|
+
|
|
704
|
+
if user_entity is None:
|
|
705
|
+
print(
|
|
706
|
+
f'Error: auth.strategy "{strategy}" requires a "User" entity in '
|
|
707
|
+
"your model specifications, but none was found.\n\n"
|
|
708
|
+
'Define a User entity with a "password_hash" field in one of your '
|
|
709
|
+
"*.model.json files."
|
|
710
|
+
)
|
|
711
|
+
sys.exit(1)
|
|
712
|
+
|
|
713
|
+
fields = user_entity.get("fields", {}) or {}
|
|
714
|
+
if "password_hash" not in fields:
|
|
715
|
+
print(
|
|
716
|
+
f'Error: auth.strategy "{strategy}" requires the "User" entity to '
|
|
717
|
+
'have a "password_hash" field, but none was found.\n\n'
|
|
718
|
+
"Add to your User entity:\n\n"
|
|
719
|
+
' "password_hash": {\n'
|
|
720
|
+
' "type": "text",\n'
|
|
721
|
+
' "max_length": 255,\n'
|
|
722
|
+
' "required": true,\n'
|
|
723
|
+
' "api_field_name": "password",\n'
|
|
724
|
+
' "api_exclude_response": true,\n'
|
|
725
|
+
' "api_exclude_update": true\n'
|
|
726
|
+
" }"
|
|
727
|
+
)
|
|
728
|
+
sys.exit(1)
|
|
729
|
+
|
|
730
|
+
for required_field in ("username", "email", "last_login_at"):
|
|
731
|
+
if required_field not in fields:
|
|
732
|
+
print(
|
|
733
|
+
f'Error: auth.strategy "{strategy}" requires the "User" entity '
|
|
734
|
+
f'to have a "{required_field}" field, but none was found.\n\n'
|
|
735
|
+
"The generated auth router uses this field to register, "
|
|
736
|
+
"authenticate, or track user sessions."
|
|
737
|
+
)
|
|
738
|
+
sys.exit(1)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _generate_target(
|
|
742
|
+
target: str,
|
|
743
|
+
model: dict[str, Any],
|
|
744
|
+
config: dict[str, Any],
|
|
745
|
+
env: Any,
|
|
746
|
+
project_root: Path,
|
|
747
|
+
model_path: Path,
|
|
748
|
+
enums: dict[str, Any],
|
|
749
|
+
constraints: dict[str, Any],
|
|
750
|
+
no_root_files: bool = False,
|
|
751
|
+
) -> dict[str, Any] | list[dict[str, Any]] | None:
|
|
752
|
+
"""Generate a single target, returning output dict(s) or None."""
|
|
753
|
+
# Use dispatch table for simple generators
|
|
754
|
+
if target in GENERATORS:
|
|
755
|
+
return GENERATORS[target](model, config, env, project_root, model_path)
|
|
756
|
+
|
|
757
|
+
# Handle special cases
|
|
758
|
+
if target == "api-tests":
|
|
759
|
+
return generate_api_tests(model, config, env, project_root, enums, constraints)
|
|
760
|
+
elif target == "api-tests-config":
|
|
761
|
+
return generate_conftest(model, config, env, project_root, model_path)
|
|
762
|
+
elif target == "api-routes":
|
|
763
|
+
return generate_api_routes(model, config, env, project_root, enums, constraints)
|
|
764
|
+
elif target == "migration-init":
|
|
765
|
+
return generate_migration_init(
|
|
766
|
+
model, config, env, project_root, no_root_files=no_root_files
|
|
767
|
+
)
|
|
768
|
+
elif target == "migration-autogen":
|
|
769
|
+
return generate_migration_autogen(model, config, env, project_root)
|
|
770
|
+
|
|
771
|
+
return None
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _process_outputs(
|
|
775
|
+
outputs: list[dict[str, Any]], diff: bool, dry_run: bool
|
|
776
|
+
) -> list[Path]:
|
|
777
|
+
"""Write outputs to files, returning list of generated paths."""
|
|
778
|
+
generated_files = []
|
|
779
|
+
|
|
780
|
+
for output in outputs:
|
|
781
|
+
path = output["path"]
|
|
782
|
+
content = output["content"]
|
|
783
|
+
mode = output.get("mode", "write")
|
|
784
|
+
|
|
785
|
+
if diff:
|
|
786
|
+
print(f"\n--- {path} ---")
|
|
787
|
+
if mode == "append":
|
|
788
|
+
print(f"[Would append - {output.get('new_count', 0)} new items]")
|
|
789
|
+
elif path.exists():
|
|
790
|
+
print("[Would update existing file]")
|
|
791
|
+
else:
|
|
792
|
+
print("[Would create new file]")
|
|
793
|
+
print(content[:500] + "..." if len(content) > 500 else content)
|
|
794
|
+
continue
|
|
795
|
+
|
|
796
|
+
if dry_run:
|
|
797
|
+
action = "append to" if mode == "append" else "write"
|
|
798
|
+
print(f" Would {action}: {path}")
|
|
799
|
+
continue
|
|
800
|
+
|
|
801
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
802
|
+
|
|
803
|
+
if mode == "append":
|
|
804
|
+
with open(path, "a") as f:
|
|
805
|
+
f.write(content)
|
|
806
|
+
new_count = output.get("new_count", 0)
|
|
807
|
+
skipped = output.get("skipped", 0)
|
|
808
|
+
print(f" ✅ Appended {new_count} item(s) to: {path}")
|
|
809
|
+
if skipped > 0:
|
|
810
|
+
print(f" (skipped {skipped} already existing)")
|
|
811
|
+
else:
|
|
812
|
+
with open(path, "w") as f:
|
|
813
|
+
f.write(content)
|
|
814
|
+
print(f" ✅ Generated: {path}")
|
|
815
|
+
|
|
816
|
+
generated_files.append(path)
|
|
817
|
+
|
|
818
|
+
return generated_files
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def main() -> None:
|
|
822
|
+
parser = argparse.ArgumentParser(description="Generate code from model definitions")
|
|
823
|
+
parser.add_argument(
|
|
824
|
+
"--version",
|
|
825
|
+
action="version",
|
|
826
|
+
version=f"%(prog)s {__version__}",
|
|
827
|
+
)
|
|
828
|
+
parser.add_argument(
|
|
829
|
+
"model",
|
|
830
|
+
type=Path,
|
|
831
|
+
nargs="?",
|
|
832
|
+
default=None,
|
|
833
|
+
help="Model JSON file or directory containing *.model.json files",
|
|
834
|
+
)
|
|
835
|
+
parser.add_argument(
|
|
836
|
+
"--interactive",
|
|
837
|
+
action="store_true",
|
|
838
|
+
help="Launch interactive wizard",
|
|
839
|
+
)
|
|
840
|
+
parser.add_argument(
|
|
841
|
+
"--target",
|
|
842
|
+
choices=TARGETS,
|
|
843
|
+
default="all",
|
|
844
|
+
help="Generation target (default: all)",
|
|
845
|
+
)
|
|
846
|
+
parser.add_argument(
|
|
847
|
+
"--diff",
|
|
848
|
+
action="store_true",
|
|
849
|
+
help="Show what would be generated without writing",
|
|
850
|
+
)
|
|
851
|
+
parser.add_argument(
|
|
852
|
+
"--dry-run",
|
|
853
|
+
action="store_true",
|
|
854
|
+
help="Show files that would be created without writing",
|
|
855
|
+
)
|
|
856
|
+
parser.add_argument(
|
|
857
|
+
"--clean",
|
|
858
|
+
action="store_true",
|
|
859
|
+
help="Delete generated files before generating",
|
|
860
|
+
)
|
|
861
|
+
parser.add_argument(
|
|
862
|
+
"--scope",
|
|
863
|
+
choices=["selective", "full"],
|
|
864
|
+
default="selective",
|
|
865
|
+
help="Cleanup scope (requires --clean)",
|
|
866
|
+
)
|
|
867
|
+
parser.add_argument(
|
|
868
|
+
"--clear-only",
|
|
869
|
+
action="store_true",
|
|
870
|
+
help="Only delete generated files without regenerating",
|
|
871
|
+
)
|
|
872
|
+
parser.add_argument(
|
|
873
|
+
"--no-root-files",
|
|
874
|
+
action="store_true",
|
|
875
|
+
help=(
|
|
876
|
+
"Skip pyproject.toml, alembic.ini, and .gitignore emission "
|
|
877
|
+
"(for the scratch-and-migrate workflow)"
|
|
878
|
+
),
|
|
879
|
+
)
|
|
880
|
+
parser.add_argument(
|
|
881
|
+
"--stack",
|
|
882
|
+
default="python-fastapi",
|
|
883
|
+
help="Stack configuration to use (default: python-fastapi)",
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
args = parser.parse_args()
|
|
887
|
+
|
|
888
|
+
# Handle --interactive: launch wizard and exit
|
|
889
|
+
if args.interactive:
|
|
890
|
+
from .wizard import run_wizard
|
|
891
|
+
|
|
892
|
+
run_wizard()
|
|
893
|
+
return
|
|
894
|
+
|
|
895
|
+
# Handle --clear-only: just cleanup and exit (doesn't require model argument)
|
|
896
|
+
if args.clear_only:
|
|
897
|
+
project_root = Path.cwd()
|
|
898
|
+
if not (project_root / ".model-generator.yaml").exists():
|
|
899
|
+
parent = project_root.parent
|
|
900
|
+
if (parent / ".model-generator.yaml").exists():
|
|
901
|
+
project_root = parent
|
|
902
|
+
_validate_project_root(project_root)
|
|
903
|
+
cleanup_generated(project_root, scope=args.scope, dry_run=args.dry_run)
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
# Model is required for all other operations
|
|
907
|
+
if args.model is None:
|
|
908
|
+
print("Error: Model argument is required (unless using --clear-only)")
|
|
909
|
+
sys.exit(1)
|
|
910
|
+
|
|
911
|
+
if not args.model.exists():
|
|
912
|
+
print(f"Error: Model file or directory not found: {args.model}")
|
|
913
|
+
sys.exit(1)
|
|
914
|
+
|
|
915
|
+
if args.clean and args.dry_run:
|
|
916
|
+
print(f"🗑️ Preview mode: Would clean {args.scope} scope then generate")
|
|
917
|
+
|
|
918
|
+
# Handle directory or single file
|
|
919
|
+
model_files = []
|
|
920
|
+
if args.model.is_dir():
|
|
921
|
+
model_files = sorted(args.model.glob("*.model.json"))
|
|
922
|
+
if not model_files:
|
|
923
|
+
print(f"Error: No *.model.json files found in {args.model}")
|
|
924
|
+
sys.exit(1)
|
|
925
|
+
else:
|
|
926
|
+
model_files = [args.model]
|
|
927
|
+
|
|
928
|
+
# Find project root; falls back to model_path.parent.parent when no config in cwd
|
|
929
|
+
project_root = _find_project_root(model_files[0])
|
|
930
|
+
_validate_project_root(project_root)
|
|
931
|
+
|
|
932
|
+
# Cleanup if requested
|
|
933
|
+
if args.clean:
|
|
934
|
+
cleanup_generated(project_root, scope=args.scope, dry_run=args.dry_run)
|
|
935
|
+
if not args.dry_run:
|
|
936
|
+
print()
|
|
937
|
+
|
|
938
|
+
config = load_config(args.stack)
|
|
939
|
+
env = get_template_env(args.stack, config)
|
|
940
|
+
layout = get_layout(config)
|
|
941
|
+
|
|
942
|
+
# Build module-name lists for infrastructure templates that import per-entity
|
|
943
|
+
# (or per-domain) generated modules. `domains` is still the per-spec domain
|
|
944
|
+
# list; `route_modules` and `factory_modules` are the layout-aware module
|
|
945
|
+
# stems that main.py and the root conftest import from. `factory_modules`
|
|
946
|
+
# mirrors the per-domain gating: only include factories from domains that
|
|
947
|
+
# have at least one API-enabled entity (parity with `domains`).
|
|
948
|
+
domains: list[str] = []
|
|
949
|
+
route_modules: list[str] = []
|
|
950
|
+
factory_modules: list[str] = []
|
|
951
|
+
extra_deps: list[str] = []
|
|
952
|
+
loaded_models: list[dict[str, Any]] = []
|
|
953
|
+
for model_file in model_files:
|
|
954
|
+
model = load_model(model_file)
|
|
955
|
+
loaded_models.append(model)
|
|
956
|
+
domain = model.get("domain", "unknown")
|
|
957
|
+
has_api = any(
|
|
958
|
+
e.get("api", {}).get("enabled", True)
|
|
959
|
+
for e in model.get("entities", {}).values()
|
|
960
|
+
)
|
|
961
|
+
if domain not in domains and has_api:
|
|
962
|
+
domains.append(domain)
|
|
963
|
+
|
|
964
|
+
if layout == "per-entity":
|
|
965
|
+
for name, entity in model.get("entities", {}).items():
|
|
966
|
+
stem = snake_case(name)
|
|
967
|
+
if (
|
|
968
|
+
entity.get("api", {}).get("enabled", True)
|
|
969
|
+
and stem not in route_modules
|
|
970
|
+
):
|
|
971
|
+
route_modules.append(stem)
|
|
972
|
+
if has_api and stem not in factory_modules:
|
|
973
|
+
factory_modules.append(stem)
|
|
974
|
+
|
|
975
|
+
extra_deps.extend(model.get("dependencies", []))
|
|
976
|
+
extra_deps = sorted(set(extra_deps))
|
|
977
|
+
|
|
978
|
+
_validate_auth_strategy(loaded_models, config)
|
|
979
|
+
|
|
980
|
+
auth_extra = _compute_auth_extra(config)
|
|
981
|
+
if auth_extra:
|
|
982
|
+
extra_deps = sorted(set(extra_deps + auth_extra))
|
|
983
|
+
|
|
984
|
+
if layout != "per-entity":
|
|
985
|
+
route_modules = list(domains)
|
|
986
|
+
factory_modules = list(domains)
|
|
987
|
+
|
|
988
|
+
has_encrypted_binary = _has_encrypted_binary_field(loaded_models)
|
|
989
|
+
|
|
990
|
+
# Generate infrastructure
|
|
991
|
+
if (
|
|
992
|
+
args.target in ("all", "infrastructure")
|
|
993
|
+
or args.target in INFRASTRUCTURE_TARGETS
|
|
994
|
+
):
|
|
995
|
+
infra_files = generate_infrastructure(
|
|
996
|
+
config=config,
|
|
997
|
+
env=env,
|
|
998
|
+
project_root=project_root,
|
|
999
|
+
domains=domains,
|
|
1000
|
+
route_modules=route_modules,
|
|
1001
|
+
factory_modules=factory_modules,
|
|
1002
|
+
project_config=config,
|
|
1003
|
+
extra_deps=extra_deps,
|
|
1004
|
+
diff=args.diff,
|
|
1005
|
+
dry_run=args.dry_run,
|
|
1006
|
+
has_encrypted_binary=has_encrypted_binary,
|
|
1007
|
+
no_root_files=args.no_root_files,
|
|
1008
|
+
)
|
|
1009
|
+
if infra_files and not args.dry_run and not args.diff:
|
|
1010
|
+
run_quality_tools(config, project_root, infra_files)
|
|
1011
|
+
|
|
1012
|
+
if args.target == "infrastructure":
|
|
1013
|
+
print("\n✅ Infrastructure generation complete")
|
|
1014
|
+
return
|
|
1015
|
+
|
|
1016
|
+
# Generate for each model
|
|
1017
|
+
for model_file in model_files:
|
|
1018
|
+
print(f"\nGenerating from: {model_file}")
|
|
1019
|
+
generate(
|
|
1020
|
+
model_path=model_file,
|
|
1021
|
+
target=args.target,
|
|
1022
|
+
diff=args.diff,
|
|
1023
|
+
dry_run=args.dry_run,
|
|
1024
|
+
stack=args.stack,
|
|
1025
|
+
no_root_files=args.no_root_files,
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
if __name__ == "__main__":
|
|
1030
|
+
main()
|