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