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,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()