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