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,219 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Model JSON Validator.
4
+
5
+ Validates model definition JSON files against the multi-entity schema.
6
+
7
+ Usage:
8
+ python validate.py <model.json>
9
+ python validate.py models/users.model.json
10
+ python validate.py --all models/
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any, cast
18
+
19
+ from jsonschema import Draft7Validator
20
+
21
+ from . import __version__
22
+
23
+
24
+ def load_schema() -> dict[str, Any]:
25
+ """Load the model schema from the skill directory."""
26
+ script_dir = Path(__file__).parent
27
+ schema_path = script_dir / "schema" / "model.schema.json"
28
+
29
+ if not schema_path.exists():
30
+ print(f"Error: Schema not found at {schema_path}")
31
+ sys.exit(1)
32
+
33
+ with open(schema_path) as f:
34
+ return cast(dict[str, Any], json.load(f))
35
+
36
+
37
+ def validate_model(model_path: Path, schema: dict[str, Any]) -> list[str]:
38
+ """
39
+ Validate a model JSON file against the schema.
40
+
41
+ Returns list of error messages (empty if valid).
42
+ """
43
+ errors = []
44
+
45
+ if not model_path.exists():
46
+ return [f"File not found: {model_path}"]
47
+
48
+ try:
49
+ with open(model_path) as f:
50
+ model = json.load(f)
51
+ except json.JSONDecodeError as e:
52
+ return [f"Invalid JSON: {e}"]
53
+
54
+ # Validate against schema
55
+ validator = Draft7Validator(schema)
56
+ for error in sorted(validator.iter_errors(model), key=lambda e: e.path):
57
+ path = " -> ".join(str(p) for p in error.path) if error.path else "(root)"
58
+ errors.append(f" {path}: {error.message}")
59
+
60
+ # Additional semantic validations
61
+ if not errors:
62
+ errors.extend(validate_semantics(model))
63
+
64
+ return errors
65
+
66
+
67
+ def validate_semantics(model: dict[str, Any]) -> list[str]:
68
+ """
69
+ Perform semantic validations beyond schema.
70
+
71
+ Validates each entity for:
72
+ - Primary key field exists
73
+ - Enum fields have values or reference existing enum
74
+ - Reference fields point to valid tables
75
+ - Constraint field references exist
76
+ - Relationship targets exist within the domain
77
+ - Immutable entity constraints
78
+ """
79
+ errors = []
80
+ entities = model.get("entities", {})
81
+
82
+ for entity_name, entity in entities.items():
83
+ prefix = f" [{entity_name}]"
84
+ fields = entity.get("fields", {})
85
+
86
+ # Check for primary key
87
+ has_pk = any(f.get("primary_key", False) for f in fields.values())
88
+ if not has_pk:
89
+ errors.append(f"{prefix} No primary key field defined")
90
+
91
+ # Check enum fields
92
+ for field_name, field in fields.items():
93
+ if field.get("type") == "enum":
94
+ if not field.get("enum_name"):
95
+ errors.append(
96
+ f"{prefix} Enum field '{field_name}' missing enum_name"
97
+ )
98
+ if not field.get("enum_values") and not field.get("enum_existing"):
99
+ errors.append(
100
+ f"{prefix} Enum field '{field_name}' "
101
+ "needs enum_values or enum_existing=true"
102
+ )
103
+
104
+ # Check reference fields
105
+ for field_name, field in fields.items():
106
+ if field.get("type") == "reference":
107
+ if not field.get("reference_table"):
108
+ errors.append(
109
+ f"{prefix} Reference field '{field_name}' "
110
+ "missing reference_table"
111
+ )
112
+
113
+ # Check field constraints reference valid fields
114
+ for field_name, field in fields.items():
115
+ for constraint in field.get("constraints", []):
116
+ if constraint.get("type") == "depends":
117
+ other_field = constraint.get("other_field")
118
+ if other_field and other_field not in fields:
119
+ errors.append(
120
+ f"{prefix} Constraint on '{field_name}' references "
121
+ f"non-existent field '{other_field}'"
122
+ )
123
+
124
+ # Check table constraints reference valid fields
125
+ for constraint in entity.get("constraints", []):
126
+ for field_ref in constraint.get("fields", []):
127
+ if field_ref not in fields:
128
+ errors.append(
129
+ f"{prefix} Table constraint references "
130
+ f"non-existent field '{field_ref}'"
131
+ )
132
+
133
+ # Collect all known field names (explicit + timestamp-generated)
134
+ all_field_names = set(fields.keys())
135
+ timestamps = entity.get("timestamps", {})
136
+ if timestamps.get("created", True):
137
+ all_field_names.add("created_at")
138
+ if timestamps.get("updated", True):
139
+ all_field_names.add("updated_at")
140
+
141
+ # Check index fields exist
142
+ for index in entity.get("indexes", []):
143
+ for field_ref in index.get("fields", []):
144
+ if field_ref not in all_field_names:
145
+ errors.append(
146
+ f"{prefix} Index references non-existent field '{field_ref}'"
147
+ )
148
+
149
+ # Check immutable entities
150
+ if entity.get("mutability") == "immutable":
151
+ if entity.get("timestamps", {}).get("updated"):
152
+ errors.append(
153
+ f"{prefix} Immutable entities should not have updated_at timestamp"
154
+ )
155
+ api_endpoints = entity.get("api", {}).get("endpoints", [])
156
+ if "update" in api_endpoints or "delete" in api_endpoints:
157
+ errors.append(
158
+ f"{prefix} Immutable entities should not have "
159
+ "'update' or 'delete' endpoints"
160
+ )
161
+
162
+ # Relationship targets: cross-domain refs are allowed, no validation here.
163
+
164
+ return errors
165
+
166
+
167
+ def main() -> None:
168
+ parser = argparse.ArgumentParser(description="Validate model JSON files")
169
+ parser.add_argument(
170
+ "--version",
171
+ action="version",
172
+ version=f"%(prog)s {__version__}",
173
+ )
174
+ parser.add_argument(
175
+ "path",
176
+ type=Path,
177
+ help="Model JSON file or directory containing model files",
178
+ )
179
+ parser.add_argument(
180
+ "--all",
181
+ action="store_true",
182
+ help="Validate all *.model.json files in directory",
183
+ )
184
+
185
+ args = parser.parse_args()
186
+ schema = load_schema()
187
+
188
+ if args.path.is_dir() or args.all:
189
+ directory = args.path if args.path.is_dir() else args.path.parent
190
+ model_files = list(directory.glob("*.model.json"))
191
+ if not model_files:
192
+ print(f"No *.model.json files found in {directory}")
193
+ sys.exit(1)
194
+ else:
195
+ model_files = [args.path]
196
+
197
+ all_valid = True
198
+ for model_path in sorted(model_files):
199
+ errors = validate_model(model_path, schema)
200
+ if errors:
201
+ all_valid = False
202
+ print(f"\n❌ {model_path.name}:")
203
+ for error in errors:
204
+ print(error)
205
+ else:
206
+ # Show entity count
207
+ with open(model_path) as f:
208
+ model = json.load(f)
209
+ entity_count = len(model.get("entities", {}))
210
+ print(f"✅ {model_path.name}: Valid ({entity_count} entities)")
211
+
212
+ if not all_valid:
213
+ sys.exit(1)
214
+
215
+ print(f"\n✅ All {len(model_files)} model(s) valid")
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()
@@ -0,0 +1,10 @@
1
+ """
2
+ Interactive wizard for model-generator.
3
+
4
+ Usage:
5
+ model-gen --interactive
6
+ """
7
+
8
+ from .menu import run_menu as run_wizard
9
+
10
+ __all__ = ["run_wizard"]
@@ -0,0 +1 @@
1
+ """Wizard actions package."""
@@ -0,0 +1,55 @@
1
+ """
2
+ Action: Clean generated files.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from ..prompts import confirm, select
10
+
11
+
12
+ def _find_project_root() -> Path:
13
+ """Find project root by looking for .model-generator.yaml."""
14
+ cwd = Path.cwd()
15
+ if (cwd / ".model-generator.yaml").exists():
16
+ return cwd
17
+ parent = cwd.parent
18
+ if (parent / ".model-generator.yaml").exists():
19
+ return parent
20
+ return cwd
21
+
22
+
23
+ def run_clean() -> None:
24
+ """Select scope, preview, confirm, and clean generated files."""
25
+ project_root = _find_project_root()
26
+
27
+ if not (project_root / ".model-generator.yaml").exists():
28
+ print("\nNo .model-generator.yaml found.")
29
+ print("Run 'Setup/update project settings' first.")
30
+ return
31
+
32
+ scope = select(
33
+ "Cleanup scope:",
34
+ choices=["selective", "full"],
35
+ default="selective",
36
+ )
37
+
38
+ print(f"\nScope: {scope}")
39
+ if scope == "selective":
40
+ print(" Will remove only files that would be regenerated.")
41
+ else:
42
+ print(" Will remove all generated directories, caches, and venvs.")
43
+
44
+ # Dry run first
45
+ from ...generate import cleanup_generated
46
+
47
+ print("\nPreview (what would be deleted):")
48
+ cleanup_generated(project_root, scope=scope, dry_run=True)
49
+
50
+ if not confirm("\nProceed with deletion?", default=False):
51
+ print("Cancelled.")
52
+ return
53
+
54
+ cleanup_generated(project_root, scope=scope, dry_run=False)
55
+ print("\nCleanup complete.")
@@ -0,0 +1,166 @@
1
+ """
2
+ Action: Generate code from model specifications.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..prompts import checkbox, confirm, select
11
+
12
+ INFRASTRUCTURE_TARGETS = {"all", "infrastructure"}
13
+
14
+
15
+ def _find_project_root() -> Path:
16
+ """Find project root by looking for .model-generator.yaml."""
17
+ cwd = Path.cwd()
18
+ if (cwd / ".model-generator.yaml").exists():
19
+ return cwd
20
+ parent = cwd.parent
21
+ if (parent / ".model-generator.yaml").exists():
22
+ return parent
23
+ return cwd
24
+
25
+
26
+ def _find_models_dir(project_root: Path) -> Path | None:
27
+ """Find the models directory."""
28
+ models_dir = project_root / "models"
29
+ if models_dir.exists():
30
+ return models_dir
31
+ return None
32
+
33
+
34
+ def run_generate() -> None:
35
+ """Select domains and targets, then run generation."""
36
+ project_root = _find_project_root()
37
+
38
+ if not (project_root / ".model-generator.yaml").exists():
39
+ print("\nNo .model-generator.yaml found.")
40
+ print("Run 'Setup/update project settings' first.")
41
+ return
42
+
43
+ models_dir = _find_models_dir(project_root)
44
+ if models_dir is None:
45
+ print(f"\nNo models/ directory found in {project_root}")
46
+ return
47
+
48
+ # Scan for model files
49
+ model_files = sorted(models_dir.glob("*.model.json"))
50
+ if not model_files:
51
+ print(f"\nNo *.model.json files found in {models_dir}")
52
+ return
53
+
54
+ domain_names = [f.stem.replace(".model", "") for f in model_files]
55
+ print(f"\nFound {len(model_files)} domain(s): {', '.join(domain_names)}")
56
+
57
+ # Select domains
58
+ if len(model_files) > 1:
59
+ selected = checkbox(
60
+ "Select domains to generate:",
61
+ choices=["all"] + domain_names,
62
+ )
63
+ if "all" in selected:
64
+ selected_files = model_files
65
+ else:
66
+ selected_files = [
67
+ f for f in model_files if f.stem.replace(".model", "") in selected
68
+ ]
69
+ else:
70
+ selected_files = model_files
71
+
72
+ if not selected_files:
73
+ print("No domains selected.")
74
+ return
75
+
76
+ # Select target
77
+ targets = [
78
+ "all",
79
+ "infrastructure",
80
+ "database",
81
+ "factories",
82
+ "api-models",
83
+ "api-routes",
84
+ "api-tests",
85
+ "enums",
86
+ "constraints",
87
+ "migration-init",
88
+ ]
89
+ target = select("Generation target:", choices=targets, default="all")
90
+
91
+ # Confirm
92
+ file_names = [f.name for f in selected_files]
93
+ print(f"\nWill generate '{target}' for: {', '.join(file_names)}")
94
+ if not confirm("Proceed?"):
95
+ return
96
+
97
+ # Generate infrastructure if needed (same logic as main())
98
+ if target in INFRASTRUCTURE_TARGETS:
99
+ from ...generate import _has_encrypted_binary_field
100
+ from ...generators.infrastructure import generate_infrastructure
101
+ from ...utils import (
102
+ get_layout,
103
+ get_template_env,
104
+ load_config,
105
+ load_model,
106
+ run_quality_tools,
107
+ )
108
+ from ...utils.templates import snake_case
109
+
110
+ stack = "python-fastapi"
111
+ config = load_config(stack)
112
+ env = get_template_env(stack, config)
113
+ layout = get_layout(config)
114
+
115
+ domains: list[str] = []
116
+ route_modules: list[str] = []
117
+ factory_modules: list[str] = []
118
+ loaded_models: list[dict[str, Any]] = []
119
+ for model_file in selected_files:
120
+ model = load_model(model_file)
121
+ loaded_models.append(model)
122
+ domain = model.get("domain", "unknown")
123
+ has_api = any(
124
+ e.get("api", {}).get("enabled", True)
125
+ for e in model.get("entities", {}).values()
126
+ )
127
+ if domain not in domains and has_api:
128
+ domains.append(domain)
129
+ if layout == "per-entity":
130
+ for name, entity in model.get("entities", {}).items():
131
+ stem = snake_case(name)
132
+ if (
133
+ entity.get("api", {}).get("enabled", True)
134
+ and stem not in route_modules
135
+ ):
136
+ route_modules.append(stem)
137
+ if has_api and stem not in factory_modules:
138
+ factory_modules.append(stem)
139
+ if layout != "per-entity":
140
+ route_modules = list(domains)
141
+ factory_modules = list(domains)
142
+
143
+ infra_files = generate_infrastructure(
144
+ config=config,
145
+ env=env,
146
+ project_root=project_root,
147
+ domains=domains,
148
+ route_modules=route_modules,
149
+ factory_modules=factory_modules,
150
+ project_config=config,
151
+ has_encrypted_binary=_has_encrypted_binary_field(loaded_models),
152
+ )
153
+ if infra_files:
154
+ run_quality_tools(config, project_root, infra_files)
155
+
156
+ if target == "infrastructure":
157
+ print("\nInfrastructure generation complete.")
158
+ return
159
+
160
+ # Generate per domain using the existing generate() function
161
+ from ...generate import generate
162
+
163
+ for model_file in selected_files:
164
+ generate(model_path=model_file, target=target)
165
+
166
+ print("\nGeneration complete.")
@@ -0,0 +1,142 @@
1
+ """
2
+ Action: Setup or update project settings (.model-generator.yaml).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from ..prompts import confirm, select, text
13
+
14
+
15
+ def _find_project_root() -> Path:
16
+ """Find project root by looking for .model-generator.yaml."""
17
+ cwd = Path.cwd()
18
+ if (cwd / ".model-generator.yaml").exists():
19
+ return cwd
20
+ parent = cwd.parent
21
+ if (parent / ".model-generator.yaml").exists():
22
+ return parent
23
+ return cwd
24
+
25
+
26
+ def _scan_stacks() -> list[str]:
27
+ """Scan available stacks from the stacks directory."""
28
+ stacks_dir = Path(__file__).parent.parent / "stacks"
29
+ if not stacks_dir.exists():
30
+ return ["python-fastapi"]
31
+ return sorted(
32
+ d.name
33
+ for d in stacks_dir.iterdir()
34
+ if d.is_dir() and not d.name.startswith(".")
35
+ )
36
+
37
+
38
+ _PATH_LAYOUTS: dict[str, dict[str, str]] = {
39
+ "full-stack (backend/src/)": {
40
+ "database_models": "backend/src/database/models",
41
+ "factories": "backend/src/database/models/factories",
42
+ "api_models": "backend/src/api/models",
43
+ "api_routes": "backend/src/api/routes",
44
+ "api_tests": "tests/contract/api",
45
+ "base": "backend/src/database/models/base.py",
46
+ "engine": "backend/src/database/engine.py",
47
+ "main": "backend/src/main.py",
48
+ "errors": "backend/src/api/errors.py",
49
+ "validators": "backend/src/api/validators.py",
50
+ "test_conftest_root": "tests/conftest.py",
51
+ "enums": "backend/src/database/models/enums.py",
52
+ "constraints": "backend/src/database/models/constraints.py",
53
+ },
54
+ "backend-only (src/)": {
55
+ "database_models": "src/database/models",
56
+ "factories": "src/database/models/factories",
57
+ "api_models": "src/api/models",
58
+ "api_routes": "src/api/routes",
59
+ "api_tests": "tests/api",
60
+ "base": "src/database/models/base.py",
61
+ "engine": "src/database/engine.py",
62
+ "main": "src/main.py",
63
+ "errors": "src/api/errors.py",
64
+ "validators": "src/api/validators.py",
65
+ "test_conftest_root": "tests/conftest.py",
66
+ "enums": "src/database/models/enums.py",
67
+ "constraints": "src/database/models/constraints.py",
68
+ },
69
+ }
70
+
71
+
72
+ def run_setup() -> None:
73
+ """Create or update .model-generator.yaml interactively."""
74
+ project_root = _find_project_root()
75
+ config_path = project_root / ".model-generator.yaml"
76
+
77
+ if config_path.exists():
78
+ _update_config(config_path)
79
+ else:
80
+ _create_config(config_path, project_root)
81
+
82
+
83
+ def _create_config(config_path: Path, project_root: Path) -> None:
84
+ """Guide user through creating a new config."""
85
+ print("\n--- Create Project Settings ---\n")
86
+
87
+ name = text("Project name:", default=project_root.name)
88
+ description = text("Description (optional):", default="")
89
+ version = text("Version:", default="0.1.0")
90
+
91
+ stacks = _scan_stacks()
92
+ stack = select("Stack:", choices=stacks, default="python-fastapi")
93
+
94
+ layout_choices = list(_PATH_LAYOUTS.keys()) + ["custom"]
95
+ layout = select("Path layout:", choices=layout_choices, default=layout_choices[0])
96
+
97
+ config: dict[str, Any] = {
98
+ "project": {"name": name, "version": version},
99
+ "stack": stack,
100
+ }
101
+ if description:
102
+ config["project"]["description"] = description
103
+
104
+ if layout != "custom" and layout in _PATH_LAYOUTS:
105
+ config["paths"] = _PATH_LAYOUTS[layout]
106
+
107
+ config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
108
+ print(f"\nCreated: {config_path}")
109
+
110
+ # Create models directory
111
+ models_dir = project_root / "models"
112
+ if not models_dir.exists():
113
+ if confirm("Create models/ directory?"):
114
+ models_dir.mkdir(parents=True)
115
+ print(f"Created: {models_dir}")
116
+
117
+
118
+ def _update_config(config_path: Path) -> None:
119
+ """Show current config and offer updates."""
120
+ print("\n--- Update Project Settings ---\n")
121
+
122
+ with open(config_path) as f:
123
+ config = yaml.safe_load(f) or {}
124
+
125
+ print("Current configuration:")
126
+ print(yaml.dump(config, default_flow_style=False))
127
+
128
+ if not confirm("Update settings?", default=False):
129
+ return
130
+
131
+ project = config.get("project", {})
132
+ project["name"] = text("Project name:", default=project.get("name", ""))
133
+ project["version"] = text("Version:", default=project.get("version", "0.1.0"))
134
+ config["project"] = project
135
+
136
+ stacks = _scan_stacks()
137
+ config["stack"] = select(
138
+ "Stack:", choices=stacks, default=config.get("stack", "python-fastapi")
139
+ )
140
+
141
+ config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
142
+ print(f"\nUpdated: {config_path}")
@@ -0,0 +1,60 @@
1
+ """
2
+ Action: Run tests via pytest.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from ..prompts import confirm, select
12
+
13
+
14
+ def _find_project_root() -> Path:
15
+ """Find project root by looking for .model-generator.yaml."""
16
+ cwd = Path.cwd()
17
+ if (cwd / ".model-generator.yaml").exists():
18
+ return cwd
19
+ parent = cwd.parent
20
+ if (parent / ".model-generator.yaml").exists():
21
+ return parent
22
+ return cwd
23
+
24
+
25
+ def _find_test_dir(project_root: Path) -> Path | None:
26
+ """Find the test directory."""
27
+ for candidate in ["tests", "test"]:
28
+ test_dir = project_root / candidate
29
+ if test_dir.exists():
30
+ return test_dir
31
+ return None
32
+
33
+
34
+ def run_tests() -> None:
35
+ """Run pytest with selected options."""
36
+ project_root = _find_project_root()
37
+ test_dir = _find_test_dir(project_root)
38
+
39
+ if test_dir is None:
40
+ print(f"\nNo tests/ directory found in {project_root}")
41
+ return
42
+
43
+ mode = select(
44
+ "Test mode:",
45
+ choices=["all tests", "verbose (-v)", "with coverage"],
46
+ default="all tests",
47
+ )
48
+
49
+ cmd = [sys.executable, "-m", "pytest", str(test_dir)]
50
+
51
+ if mode == "verbose (-v)":
52
+ cmd.append("-v")
53
+ elif mode == "with coverage":
54
+ cmd.extend(["--cov", "--cov-report=term-missing"])
55
+
56
+ print(f"\nRunning: {' '.join(cmd)}")
57
+ if not confirm("Proceed?"):
58
+ return
59
+
60
+ subprocess.run(cmd, cwd=str(project_root))