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,679 @@
1
+ """
2
+ Infrastructure file generation (base, engine, main, errors, validators, etc.).
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from jinja2 import Environment
9
+
10
+ from ..utils.constants import GENERATED_MARKER
11
+ from ..utils.templates import path_to_import
12
+
13
+
14
+ def generate_base(
15
+ config: dict[str, Any], env: Environment, project_root: Path
16
+ ) -> dict[str, Any] | None:
17
+ """Generate SQLAlchemy Base class."""
18
+ db_models = config["paths"].get("database_models", "backend/src/database/models")
19
+ base_path = config["paths"].get("base", f"{db_models}/base.py")
20
+ output_path = project_root / base_path
21
+
22
+ if output_path.exists():
23
+ return None
24
+
25
+ template = env.get_template("infrastructure/base.py.j2")
26
+ content = template.render()
27
+
28
+ return {"path": output_path, "content": content}
29
+
30
+
31
+ def generate_engine(
32
+ config: dict[str, Any], env: Environment, project_root: Path
33
+ ) -> dict[str, Any] | None:
34
+ """Generate database engine and session management."""
35
+ engine_path = config["paths"].get("engine", "backend/src/database/engine.py")
36
+ output_path = project_root / engine_path
37
+
38
+ if output_path.exists():
39
+ return None
40
+
41
+ template = env.get_template("infrastructure/engine.py.j2")
42
+ content = template.render()
43
+
44
+ return {"path": output_path, "content": content}
45
+
46
+
47
+ def generate_types(
48
+ config: dict[str, Any], env: Environment, project_root: Path
49
+ ) -> dict[str, Any] | None:
50
+ """Generate custom SQLAlchemy types (SqliteNumeric for financial/percentage)."""
51
+ db_models = config["paths"].get("database_models", "backend/src/database/models")
52
+ db_dir = str(Path(db_models).parent)
53
+ output_path = project_root / db_dir / "types.py"
54
+
55
+ if output_path.exists():
56
+ return None
57
+
58
+ template = env.get_template("infrastructure/types.py.j2")
59
+ content = template.render()
60
+
61
+ return {"path": output_path, "content": content}
62
+
63
+
64
+ def generate_database_init(
65
+ config: dict[str, Any], env: Environment, project_root: Path
66
+ ) -> dict[str, Any] | None:
67
+ """Generate database package __init__.py with get_session export."""
68
+ db_models = config["paths"].get("database_models", "backend/src/database/models")
69
+ db_dir = str(Path(db_models).parent)
70
+ output_path = project_root / db_dir / "__init__.py"
71
+
72
+ if output_path.exists():
73
+ return None
74
+
75
+ template = env.get_template("infrastructure/database_init.py.j2")
76
+ content = template.render()
77
+
78
+ return {"path": output_path, "content": content}
79
+
80
+
81
+ def generate_errors(
82
+ config: dict[str, Any], env: Environment, project_root: Path
83
+ ) -> dict[str, Any] | None:
84
+ """Generate API error formatting utilities."""
85
+ errors_path = config["paths"].get("errors", "backend/src/api/errors.py")
86
+ output_path = project_root / errors_path
87
+
88
+ if output_path.exists():
89
+ return None
90
+
91
+ template = env.get_template("infrastructure/errors.py.j2")
92
+ content = template.render()
93
+
94
+ return {"path": output_path, "content": content}
95
+
96
+
97
+ def generate_validators(
98
+ config: dict[str, Any], env: Environment, project_root: Path
99
+ ) -> dict[str, Any] | None:
100
+ """Generate API validation utilities.
101
+
102
+ Bootstrap-only: returns None when the file already exists so adopters
103
+ who add domain-specific validators are not overwritten on regeneration.
104
+ """
105
+ validators_path = config["paths"].get("validators", "backend/src/api/validators.py")
106
+ output_path = project_root / validators_path
107
+
108
+ if output_path.exists():
109
+ return None
110
+
111
+ template = env.get_template("infrastructure/validators.py.j2")
112
+ content = template.render()
113
+
114
+ return {"path": output_path, "content": content}
115
+
116
+
117
+ def generate_utils(
118
+ config: dict[str, Any], env: Environment, project_root: Path
119
+ ) -> dict[str, Any] | None:
120
+ """Generate API utility functions (normalize_decimal).
121
+
122
+ Bootstrap-only: returns None when the file already exists so adopters
123
+ who add domain-specific utilities are not overwritten on regeneration.
124
+ """
125
+ api_models_path = config["paths"].get("api_models", "backend/src/api/models")
126
+ api_dir = str(Path(api_models_path).parent)
127
+ output_path = project_root / api_dir / "utils.py"
128
+
129
+ if output_path.exists():
130
+ return None
131
+
132
+ template = env.get_template("infrastructure/utils.py.j2")
133
+ content = template.render()
134
+
135
+ return {"path": output_path, "content": content}
136
+
137
+
138
+ def generate_gitignore(
139
+ config: dict[str, Any],
140
+ env: Environment,
141
+ project_root: Path,
142
+ no_root_files: bool = False,
143
+ ) -> dict[str, Any] | None:
144
+ """Generate .gitignore for new projects (only if none exists).
145
+
146
+ Returns None when ``no_root_files`` is set, so adopters using
147
+ --no-root-files for scratch-and-migrate workflows keep their own
148
+ repository's .gitignore unchanged.
149
+ """
150
+ if no_root_files:
151
+ return None
152
+
153
+ output_path = project_root / ".gitignore"
154
+
155
+ if output_path.exists():
156
+ return None
157
+
158
+ template = env.get_template("infrastructure/gitignore.j2")
159
+ content = template.render()
160
+
161
+ return {"path": output_path, "content": content}
162
+
163
+
164
+ def generate_pyproject(
165
+ config: dict[str, Any],
166
+ env: Environment,
167
+ project_root: Path,
168
+ project_config: dict[str, Any],
169
+ extra_deps: list[str] | None = None,
170
+ no_root_files: bool = False,
171
+ ) -> dict[str, Any] | None:
172
+ """Generate pyproject.toml for new projects (only if none exists).
173
+
174
+ Returns None when ``no_root_files`` is set, so adopters using
175
+ --no-root-files for scratch-and-migrate workflows keep their own
176
+ repository's pyproject.toml unchanged.
177
+ """
178
+ if no_root_files:
179
+ return None
180
+
181
+ output_path = project_root / "pyproject.toml"
182
+
183
+ if output_path.exists():
184
+ return None
185
+
186
+ project = project_config.get("project", {})
187
+ raw_name = project.get("name", "my-project")
188
+ project_slug = raw_name.lower().replace(" ", "-")
189
+
190
+ deps = config.get("dependencies", {})
191
+ runtime_deps = deps.get("runtime", [])
192
+ dev_deps = deps.get("dev", [])
193
+
194
+ # Merge domain-level extra dependencies
195
+ if extra_deps:
196
+ runtime_deps = sorted(set(runtime_deps + extra_deps))
197
+
198
+ paths = config.get("paths", {})
199
+ validators_path = paths.get("validators", "backend/src/api/validators.py")
200
+
201
+ api_models_path = paths.get("api_models", "backend/src/api/models")
202
+ utils_path = str(Path(api_models_path).parent / "utils.py")
203
+
204
+ constraints_path = paths.get(
205
+ "constraints", "backend/src/database/models/constraints.py"
206
+ )
207
+
208
+ # Top-level source directory for mutmut also_copy
209
+ also_copy_dir = Path(validators_path).parts[0] + "/"
210
+
211
+ # Package root for setuptools discovery (e.g., "backend/src")
212
+ main_path = paths.get("main", "backend/src/main.py")
213
+ package_root = str(Path(main_path).parent)
214
+
215
+ raw_style = {
216
+ **(config.get("style") or {}),
217
+ **(project_config.get("style") or {}),
218
+ }
219
+ style = {
220
+ "python_version": raw_style.get("python_version") or "3.11",
221
+ "line_length": raw_style.get("line_length"),
222
+ "quote_style": raw_style.get("quote_style"),
223
+ "indent_style": raw_style.get("indent_style"),
224
+ }
225
+
226
+ template = env.get_template("infrastructure/pyproject.toml.j2")
227
+ content = template.render(
228
+ project=project,
229
+ project_slug=project_slug,
230
+ runtime_deps=runtime_deps,
231
+ dev_deps=dev_deps,
232
+ validators_path=validators_path,
233
+ utils_path=utils_path,
234
+ constraints_path=constraints_path,
235
+ also_copy_dir=also_copy_dir,
236
+ package_root=package_root,
237
+ style=style,
238
+ )
239
+
240
+ return {"path": output_path, "content": content}
241
+
242
+
243
+ def generate_main(
244
+ config: dict[str, Any],
245
+ env: Environment,
246
+ project_root: Path,
247
+ domains: list[str],
248
+ project_config: dict[str, Any],
249
+ route_modules: list[str] | None = None,
250
+ ) -> dict[str, Any] | None:
251
+ """Generate FastAPI main application.
252
+
253
+ ``domains`` is kept for backward compatibility. ``route_modules`` is the
254
+ layout-aware list of route module stems imported by main.py — entity
255
+ snake_case names in per-entity mode, domain names in per-domain mode.
256
+ Defaults to ``domains`` when not provided.
257
+
258
+ Bootstrap-only: returns None when the file already exists so adopters
259
+ who wire up additional routers / middleware are not overwritten on
260
+ regeneration. New domains/routes added later must be wired manually.
261
+ """
262
+ main_path = config["paths"].get("main", "backend/src/main.py")
263
+ output_path = project_root / main_path
264
+
265
+ if output_path.exists():
266
+ return None
267
+
268
+ python_root = config.get("python_root", "")
269
+
270
+ api_routes_path = config["paths"].get("api_routes", "backend/src/api/routes")
271
+ api_routes_import = path_to_import(api_routes_path, python_root=python_root)
272
+
273
+ db_models_path = config["paths"].get(
274
+ "database_models", "backend/src/database/models"
275
+ )
276
+ db_import = path_to_import(
277
+ str(Path(db_models_path).parent), python_root=python_root
278
+ )
279
+
280
+ main_dir = str(Path(main_path).parent)
281
+ main_module = path_to_import(main_dir, "main", python_root=python_root)
282
+
283
+ auth_router_import = None
284
+ auth = config.get("auth") or {}
285
+ if auth.get("strategy"):
286
+ auth_path = auth.get("path", "backend/src/auth/router.py")
287
+ auth_module_path = auth_path[:-3] if auth_path.endswith(".py") else auth_path
288
+ auth_router_import = path_to_import(auth_module_path, python_root=python_root)
289
+
290
+ csrf_module_import = None
291
+ if auth.get("strategy"):
292
+ auth_path = auth.get("path", "backend/src/auth/router.py")
293
+ csrf_module_path = str(Path(auth_path).parent / "csrf")
294
+ csrf_module_import = path_to_import(csrf_module_path, python_root=python_root)
295
+
296
+ rate_limit_module_import = None
297
+ if auth.get("strategy"):
298
+ rate_limit = auth.get("rate_limit") or {}
299
+ if rate_limit.get("enabled") is not False:
300
+ auth_path = auth.get("path", "backend/src/auth/router.py")
301
+ rate_limit_module_path = str(Path(auth_path).parent / "rate_limit")
302
+ rate_limit_module_import = path_to_import(
303
+ rate_limit_module_path, python_root=python_root
304
+ )
305
+
306
+ template = env.get_template("infrastructure/main.py.j2")
307
+ content = template.render(
308
+ domains=route_modules if route_modules is not None else domains,
309
+ api_routes_import=api_routes_import,
310
+ db_import=db_import,
311
+ main_module=main_module,
312
+ project=project_config.get("project", {}),
313
+ auth_router_import=auth_router_import,
314
+ csrf_module_import=csrf_module_import,
315
+ rate_limit_module_import=rate_limit_module_import,
316
+ )
317
+
318
+ return {"path": output_path, "content": content}
319
+
320
+
321
+ def generate_auth_router(
322
+ config: dict[str, Any],
323
+ env: Environment,
324
+ project_root: Path,
325
+ project_config: dict[str, Any],
326
+ ) -> dict[str, Any] | None:
327
+ """Generate the auth router (register / login / logout / etc.).
328
+
329
+ Emitted only when ``config.auth.strategy`` is set. Bootstrap-only:
330
+ returns None when the file already exists at the configured
331
+ ``auth.path`` so adopters who customize the router are not overwritten
332
+ on regeneration.
333
+ """
334
+ if not config.get("auth", {}).get("strategy"):
335
+ return None
336
+
337
+ auth_path = config.get("auth", {}).get("path", "backend/src/auth/router.py")
338
+ output_path = project_root / auth_path
339
+
340
+ if output_path.exists():
341
+ return None
342
+
343
+ python_root = config.get("python_root", "")
344
+
345
+ db_models_path = config["paths"].get(
346
+ "database_models", "backend/src/database/models"
347
+ )
348
+ db_models_import = path_to_import(db_models_path, python_root=python_root)
349
+ db_import = path_to_import(
350
+ str(Path(db_models_path).parent), python_root=python_root
351
+ )
352
+
353
+ template = env.get_template("infrastructure/auth_router.py.j2")
354
+ content = template.render(
355
+ config=config,
356
+ db_models_import=db_models_import,
357
+ db_import=db_import,
358
+ project=project_config.get("project", {}),
359
+ rate_limit_enabled=(config.get("auth", {}).get("rate_limit") or {}).get(
360
+ "enabled"
361
+ )
362
+ is not False,
363
+ )
364
+
365
+ return {"path": output_path, "content": content}
366
+
367
+
368
+ def generate_csrf(
369
+ config: dict[str, Any],
370
+ env: Environment,
371
+ project_root: Path,
372
+ ) -> dict[str, Any] | None:
373
+ """Generate the CSRF middleware (double-submit cookie pattern).
374
+
375
+ Emitted only when ``config.auth.strategy`` is set. The file lives next
376
+ to the auth router (sibling in the same package) so the router can
377
+ import it via ``from .csrf import set_csrf_cookie``. Bootstrap-only:
378
+ returns None when the file already exists.
379
+ """
380
+ if not config.get("auth", {}).get("strategy"):
381
+ return None
382
+
383
+ auth_path = config.get("auth", {}).get("path", "backend/src/auth/router.py")
384
+ csrf_path = str(Path(auth_path).parent / "csrf.py")
385
+ output_path = project_root / csrf_path
386
+
387
+ if output_path.exists():
388
+ return None
389
+
390
+ cookie_name = config.get("auth", {}).get("cookie_name", "session_id")
391
+
392
+ template = env.get_template("infrastructure/csrf.py.j2")
393
+ content = template.render(cookie_name=cookie_name)
394
+
395
+ return {"path": output_path, "content": content}
396
+
397
+
398
+ def generate_encrypted_bytes(
399
+ config: dict[str, Any],
400
+ env: Environment,
401
+ project_root: Path,
402
+ has_encrypted_binary: bool = False,
403
+ ) -> dict[str, Any] | None:
404
+ """Generate the EncryptedBytes TypeDecorator next to the model files.
405
+
406
+ Emitted only when at least one entity in the project has a ``binary``
407
+ field with an ``encrypt`` block — mirrors the ``ns.has_encrypted_binary``
408
+ gate that ``model.py.j2`` uses to conditionally emit
409
+ ``from .encrypted_bytes import EncryptedBytes``. Bootstrap-only:
410
+ returns None when the file already exists at the target path.
411
+ """
412
+ if not has_encrypted_binary:
413
+ return None
414
+
415
+ db_models = config["paths"].get("database_models", "backend/src/database/models")
416
+ output_path = project_root / db_models / "encrypted_bytes.py"
417
+
418
+ if output_path.exists():
419
+ return None
420
+
421
+ template = env.get_template("infrastructure/encrypted_bytes.py.j2")
422
+ content = template.render(config=config)
423
+
424
+ return {"path": output_path, "content": content}
425
+
426
+
427
+ def generate_rate_limit(
428
+ config: dict[str, Any],
429
+ env: Environment,
430
+ project_root: Path,
431
+ ) -> dict[str, Any] | None:
432
+ """Generate the rate-limit module (shared slowapi Limiter instance).
433
+
434
+ Emitted only when ``config.auth.strategy`` is set AND
435
+ ``config.auth.rate_limit.enabled`` is not False (rate limiting is on
436
+ by default for auth-enabled projects). The file lives next to the
437
+ auth router so the router can import it via
438
+ ``from .rate_limit import limiter``. Bootstrap-only: returns None
439
+ when the file already exists.
440
+ """
441
+ auth = config.get("auth") or {}
442
+ if not auth.get("strategy"):
443
+ return None
444
+
445
+ rate_limit = auth.get("rate_limit") or {}
446
+ if rate_limit.get("enabled") is False:
447
+ return None
448
+
449
+ auth_path = auth.get("path", "backend/src/auth/router.py")
450
+ rate_limit_path = str(Path(auth_path).parent / "rate_limit.py")
451
+ output_path = project_root / rate_limit_path
452
+
453
+ if output_path.exists():
454
+ return None
455
+
456
+ backend = rate_limit.get("backend", "memory")
457
+ default_storage_uri = (
458
+ "redis://localhost:6379/0" if backend == "redis" else "memory://"
459
+ )
460
+
461
+ template = env.get_template("infrastructure/rate_limit.py.j2")
462
+ content = template.render(
463
+ login_limit=rate_limit.get("login", "5/minute"),
464
+ register_limit=rate_limit.get("register", "3/hour"),
465
+ forgot_limit=rate_limit.get("forgot", "3/hour"),
466
+ default_storage_uri=default_storage_uri,
467
+ )
468
+
469
+ return {"path": output_path, "content": content}
470
+
471
+
472
+ def generate_test_conftest_root(
473
+ config: dict[str, Any],
474
+ env: Environment,
475
+ project_root: Path,
476
+ domains: list[str],
477
+ factory_modules: list[str] | None = None,
478
+ ) -> dict[str, Any] | None:
479
+ """Generate root test conftest with database and client fixtures.
480
+
481
+ ``domains`` is kept for backward compatibility. ``factory_modules`` is the
482
+ layout-aware list of factory module stems imported by conftest.py — entity
483
+ snake_case names in per-entity mode, domain names in per-domain mode.
484
+ Defaults to ``domains`` when not provided.
485
+
486
+ Bootstrap-only: returns None when the file already exists so adopters
487
+ who add fixture overrides are not overwritten on regeneration. New
488
+ factory modules added later must be wired manually.
489
+ """
490
+ conftest_path = config["paths"].get("test_conftest_root", "tests/conftest.py")
491
+ output_path = project_root / conftest_path
492
+
493
+ if output_path.exists():
494
+ return None
495
+
496
+ python_root = config.get("python_root", "")
497
+
498
+ database_models_path = config["paths"].get(
499
+ "database_models", "backend/src/database/models"
500
+ )
501
+ database_models_import = path_to_import(
502
+ database_models_path, python_root=python_root
503
+ )
504
+
505
+ main_path = config["paths"].get("main", "backend/src/main.py")
506
+ main_dir = str(Path(main_path).parent)
507
+ main_import = path_to_import(main_dir, "main", python_root=python_root)
508
+
509
+ engine_path = config["paths"].get("engine", "backend/src/database/engine.py")
510
+ engine_dir = str(Path(engine_path).parent)
511
+ engine_import = path_to_import(engine_dir, "engine", python_root=python_root)
512
+
513
+ factories_path = config["paths"].get(
514
+ "factories", "backend/src/database/models/factories"
515
+ )
516
+ factories_import = path_to_import(factories_path, python_root=python_root)
517
+
518
+ template = env.get_template("tests/conftest_root.py.j2")
519
+ content = template.render(
520
+ domains=factory_modules if factory_modules is not None else domains,
521
+ database_models_import=database_models_import,
522
+ main_import=main_import,
523
+ engine_import=engine_import,
524
+ factories_import=factories_import,
525
+ )
526
+
527
+ return {"path": output_path, "content": content}
528
+
529
+
530
+ def generate_package_init_files(
531
+ config: dict[str, Any], project_root: Path
532
+ ) -> list[dict[str, Any]]:
533
+ """
534
+ Generate __init__.py files for all package directories.
535
+
536
+ Creates empty __init__.py files in all necessary directories
537
+ to make them proper Python packages.
538
+ """
539
+ outputs = []
540
+ paths_config = config.get("paths", {})
541
+
542
+ # Collect all directories that need __init__.py
543
+ paths_to_init = []
544
+
545
+ # Main source directory
546
+ main_path = paths_config.get("main", "backend/src/main.py")
547
+ src_dir = str(Path(main_path).parent)
548
+ paths_to_init.append(src_dir)
549
+
550
+ # Database paths
551
+ db_models = paths_config.get("database_models", "backend/src/database/models")
552
+ paths_to_init.append(db_models)
553
+
554
+ # Factories
555
+ factories = paths_config.get("factories", "backend/src/database/models/factories")
556
+ paths_to_init.append(factories)
557
+
558
+ # API paths
559
+ api_models = paths_config.get("api_models", "backend/src/api/models")
560
+ paths_to_init.append(api_models)
561
+ api_dir = str(Path(api_models).parent)
562
+ paths_to_init.append(api_dir)
563
+
564
+ api_routes = paths_config.get("api_routes", "backend/src/api/routes")
565
+ paths_to_init.append(api_routes)
566
+
567
+ # Auth package (when auth.strategy is set, the auth router and CSRF
568
+ # middleware live in the same package and use relative imports).
569
+ if config.get("auth", {}).get("strategy"):
570
+ auth_path = config.get("auth", {}).get("path", "backend/src/auth/router.py")
571
+ paths_to_init.append(str(Path(auth_path).parent))
572
+
573
+ # Test paths (including parent directories)
574
+ api_tests = paths_config.get("api_tests", "tests/contract/api")
575
+ paths_to_init.append(api_tests)
576
+ test_dir = api_tests
577
+ while "/" in test_dir:
578
+ test_dir = str(Path(test_dir).parent)
579
+ paths_to_init.append(test_dir)
580
+
581
+ # Generate __init__.py for each unique path
582
+ init_content = f"{GENERATED_MARKER}\n"
583
+ for path in sorted(set(paths_to_init)):
584
+ init_path = project_root / path / "__init__.py"
585
+ if not init_path.exists():
586
+ outputs.append({"path": init_path, "content": init_content})
587
+
588
+ return outputs
589
+
590
+
591
+ def generate_infrastructure(
592
+ config: dict[str, Any],
593
+ env: Environment,
594
+ project_root: Path,
595
+ domains: list[str],
596
+ project_config: dict[str, Any],
597
+ extra_deps: list[str] | None = None,
598
+ diff: bool = False,
599
+ dry_run: bool = False,
600
+ route_modules: list[str] | None = None,
601
+ factory_modules: list[str] | None = None,
602
+ has_encrypted_binary: bool = False,
603
+ no_root_files: bool = False,
604
+ ) -> list[Path]:
605
+ """
606
+ Generate all infrastructure files.
607
+
608
+ Returns list of generated file paths.
609
+ """
610
+ print("\n🏗️ Generating infrastructure files...")
611
+
612
+ outputs = []
613
+
614
+ # Collect all infrastructure outputs
615
+ generators = [
616
+ generate_gitignore(config, env, project_root, no_root_files=no_root_files),
617
+ generate_pyproject(
618
+ config,
619
+ env,
620
+ project_root,
621
+ project_config,
622
+ extra_deps,
623
+ no_root_files=no_root_files,
624
+ ),
625
+ generate_base(config, env, project_root),
626
+ generate_engine(config, env, project_root),
627
+ generate_types(config, env, project_root),
628
+ generate_database_init(config, env, project_root),
629
+ generate_errors(config, env, project_root),
630
+ generate_validators(config, env, project_root),
631
+ generate_utils(config, env, project_root),
632
+ generate_main(
633
+ config,
634
+ env,
635
+ project_root,
636
+ domains,
637
+ project_config,
638
+ route_modules=route_modules,
639
+ ),
640
+ generate_auth_router(config, env, project_root, project_config),
641
+ generate_csrf(config, env, project_root),
642
+ generate_encrypted_bytes(
643
+ config, env, project_root, has_encrypted_binary=has_encrypted_binary
644
+ ),
645
+ generate_rate_limit(config, env, project_root),
646
+ generate_test_conftest_root(
647
+ config, env, project_root, domains, factory_modules=factory_modules
648
+ ),
649
+ ]
650
+
651
+ for result in generators:
652
+ if result:
653
+ outputs.append(result)
654
+
655
+ # Add package init files
656
+ outputs.extend(generate_package_init_files(config, project_root))
657
+
658
+ # Process outputs
659
+ generated_files = []
660
+ for output in outputs:
661
+ path = output["path"]
662
+ content = output["content"]
663
+
664
+ if diff:
665
+ print(f"\n--- {path} ---")
666
+ print(content[:500] + "..." if len(content) > 500 else content)
667
+ continue
668
+
669
+ if dry_run:
670
+ print(f" Would write: {path}")
671
+ continue
672
+
673
+ path.parent.mkdir(parents=True, exist_ok=True)
674
+ with open(path, "w") as f:
675
+ f.write(content)
676
+ print(f" ✅ Generated: {path}")
677
+ generated_files.append(path)
678
+
679
+ return generated_files