moltpy 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 (172) hide show
  1. moltpy/__init__.py +4 -0
  2. moltpy/builder.py +341 -0
  3. moltpy/cli.py +446 -0
  4. moltpy/config.py +230 -0
  5. moltpy/external.py +176 -0
  6. moltpy/generator.py +551 -0
  7. moltpy/generator_templates/model.py.j2 +36 -0
  8. moltpy/generator_templates/router.py.j2 +56 -0
  9. moltpy/generator_templates/schema.py.j2 +38 -0
  10. moltpy/generator_templates/service.py.j2 +65 -0
  11. moltpy/generator_templates/test_api.py.j2 +86 -0
  12. moltpy/modules/admin/manifest.json +20 -0
  13. moltpy/modules/admin/templates/app/admin.py.j2 +29 -0
  14. moltpy/modules/api_versioning/manifest.json +9 -0
  15. moltpy/modules/api_versioning/templates/app/routers/v1/__init__.py +1 -0
  16. moltpy/modules/api_versioning/templates/app/routers/v1/api.py.j2 +13 -0
  17. moltpy/modules/arq/manifest.json +17 -0
  18. moltpy/modules/arq/templates/app/arq.py.j2 +228 -0
  19. moltpy/modules/arq/templates/app/routers/tasks.py.j2 +70 -0
  20. moltpy/modules/arq/templates/app/tasks.py.j2 +26 -0
  21. moltpy/modules/arq/templates/scripts/worker.py.j2 +79 -0
  22. moltpy/modules/auth/manifest.json +26 -0
  23. moltpy/modules/auth/templates/app/routers/auth.py.j2 +295 -0
  24. moltpy/modules/auth/templates/tests/test_auth.py +52 -0
  25. moltpy/modules/auth_social/manifest.json +30 -0
  26. moltpy/modules/auth_social/templates/app/models/social_account.py.j2 +38 -0
  27. moltpy/modules/auth_social/templates/app/routers/oauth.py.j2 +76 -0
  28. moltpy/modules/auth_social/templates/app/schemas/oauth.py.j2 +44 -0
  29. moltpy/modules/auth_social/templates/app/services/oauth.py.j2 +260 -0
  30. moltpy/modules/base.py +51 -0
  31. moltpy/modules/celery/manifest.json +29 -0
  32. moltpy/modules/celery/templates/app/celery.py.j2 +43 -0
  33. moltpy/modules/celery/templates/app/celerybeat_schedule.py +14 -0
  34. moltpy/modules/celery/templates/app/tasks.py +64 -0
  35. moltpy/modules/cors/manifest.json +9 -0
  36. moltpy/modules/database/manifest.json +22 -0
  37. moltpy/modules/database/templates/alembic/env.py.j2 +82 -0
  38. moltpy/modules/database/templates/alembic.ini +53 -0
  39. moltpy/modules/database/templates/app/database.py.j2 +96 -0
  40. moltpy/modules/docker/manifest.json +16 -0
  41. moltpy/modules/docker/templates/.dockerignore +40 -0
  42. moltpy/modules/docker/templates/Dockerfile.j2 +61 -0
  43. moltpy/modules/docker/templates/docker-compose.yml.j2 +112 -0
  44. moltpy/modules/docs/manifest.json +9 -0
  45. moltpy/modules/docs/templates/docs/api.md.j2 +58 -0
  46. moltpy/modules/docs/templates/docs/development.md.j2 +53 -0
  47. moltpy/modules/docs/templates/docs/index.md.j2 +42 -0
  48. moltpy/modules/docs/templates/mkdocs.yml.j2 +50 -0
  49. moltpy/modules/email/manifest.json +20 -0
  50. moltpy/modules/email/templates/app/core/email.py.j2 +131 -0
  51. moltpy/modules/email/templates/app/templates/email/password_reset.html.j2 +54 -0
  52. moltpy/modules/email/templates/app/templates/email/password_reset.txt.j2 +17 -0
  53. moltpy/modules/email/templates/app/templates/email/receipt.html.j2 +71 -0
  54. moltpy/modules/email/templates/app/templates/email/receipt.txt.j2 +20 -0
  55. moltpy/modules/email/templates/app/templates/email/welcome.html.j2 +52 -0
  56. moltpy/modules/email/templates/app/templates/email/welcome.txt.j2 +15 -0
  57. moltpy/modules/events/manifest.json +9 -0
  58. moltpy/modules/events/templates/app/core/events.py.j2 +52 -0
  59. moltpy/modules/events/templates/app/events/__init__.py.j2 +3 -0
  60. moltpy/modules/events/templates/app/events/handlers.py.j2 +24 -0
  61. moltpy/modules/github_actions/manifest.json +9 -0
  62. moltpy/modules/github_actions/templates/.github/workflows/ci.yml.j2 +107 -0
  63. moltpy/modules/graphql/manifest.json +32 -0
  64. moltpy/modules/graphql/templates/app/graphql/__init__.py.j2 +3 -0
  65. moltpy/modules/graphql/templates/app/graphql/resolvers.py.j2 +3 -0
  66. moltpy/modules/graphql/templates/app/graphql/schema.py.j2 +160 -0
  67. moltpy/modules/grpc/manifest.json +15 -0
  68. moltpy/modules/grpc/templates/app/grpc/__init__.py.j2 +3 -0
  69. moltpy/modules/grpc/templates/app/grpc/server.py.j2 +43 -0
  70. moltpy/modules/grpc/templates/protos/service.proto +19 -0
  71. moltpy/modules/health/manifest.json +9 -0
  72. moltpy/modules/kubernetes/manifest.json +31 -0
  73. moltpy/modules/kubernetes/templates/k8s/00-namespace.yml.j2 +5 -0
  74. moltpy/modules/kubernetes/templates/k8s/01-secrets.yml.j2 +41 -0
  75. moltpy/modules/kubernetes/templates/k8s/02-configmap.yml.j2 +34 -0
  76. moltpy/modules/kubernetes/templates/k8s/03-deployment.yml.j2 +74 -0
  77. moltpy/modules/kubernetes/templates/k8s/04-service.yml.j2 +17 -0
  78. moltpy/modules/kubernetes/templates/k8s/05-ingress.yml.j2 +58 -0
  79. moltpy/modules/kubernetes/templates/k8s/06-postgres.yml.j2 +81 -0
  80. moltpy/modules/kubernetes/templates/k8s/07-redis.yml.j2 +59 -0
  81. moltpy/modules/kubernetes/templates/k8s/README.md.j2 +104 -0
  82. moltpy/modules/logging/manifest.json +9 -0
  83. moltpy/modules/makefile/manifest.json +9 -0
  84. moltpy/modules/makefile/templates/Makefile.j2 +86 -0
  85. moltpy/modules/notifications/manifest.json +21 -0
  86. moltpy/modules/notifications/snippets/app/arq.py.j2 +19 -0
  87. moltpy/modules/notifications/snippets/app/tasks.py.j2 +24 -0
  88. moltpy/modules/notifications/templates/app/api/v1/endpoints/notifications.py.j2 +63 -0
  89. moltpy/modules/notifications/templates/app/core/notifications.py.j2 +58 -0
  90. moltpy/modules/notifications/templates/app/schemas/notification.py.j2 +21 -0
  91. moltpy/modules/notifications/templates/app/services/notification_service.py.j2 +31 -0
  92. moltpy/modules/observability/manifest.json +20 -0
  93. moltpy/modules/observability/templates/app/middleware/telemetry.py.j2 +72 -0
  94. moltpy/modules/observability/templates/app/routers/metrics.py.j2 +50 -0
  95. moltpy/modules/observability/templates/grafana/dashboards/fastapi-overview.json +258 -0
  96. moltpy/modules/observability/templates/grafana/provisioning/dashboards/dashboards.yml +12 -0
  97. moltpy/modules/observability/templates/grafana/provisioning/datasources/prometheus.yml +9 -0
  98. moltpy/modules/openapi/manifest.json +16 -0
  99. moltpy/modules/openapi/templates/app/core/openapi.py +39 -0
  100. moltpy/modules/pagination/manifest.json +9 -0
  101. moltpy/modules/payments/manifest.json +21 -0
  102. moltpy/modules/payments/templates/app/api/v1/endpoints/payments.py.j2 +91 -0
  103. moltpy/modules/payments/templates/app/core/payments.py.j2 +39 -0
  104. moltpy/modules/payments/templates/app/schemas/payment.py.j2 +27 -0
  105. moltpy/modules/pre_commit/manifest.json +9 -0
  106. moltpy/modules/pre_commit/templates/.pre-commit-config.yaml +13 -0
  107. moltpy/modules/rate_limit/manifest.json +16 -0
  108. moltpy/modules/rate_limit/templates/app/middleware/rate_limit.py.j2 +31 -0
  109. moltpy/modules/redis/manifest.json +9 -0
  110. moltpy/modules/redis/templates/app/redis.py +55 -0
  111. moltpy/modules/scheduler/manifest.json +21 -0
  112. moltpy/modules/scheduler/templates/app/core/scheduler.py.j2 +15 -0
  113. moltpy/modules/scheduler/templates/app/jobs/__init__.py.j2 +3 -0
  114. moltpy/modules/scheduler/templates/app/jobs/example_job.py.j2 +68 -0
  115. moltpy/modules/search/manifest.json +15 -0
  116. moltpy/modules/search/snippets/docker-compose.yml.j2 +13 -0
  117. moltpy/modules/search/templates/app/core/search.py.j2 +15 -0
  118. moltpy/modules/search/templates/app/routers/search.py.j2 +18 -0
  119. moltpy/modules/search/templates/app/services/search_service.py.j2 +37 -0
  120. moltpy/modules/seeding/manifest.json +15 -0
  121. moltpy/modules/seeding/templates/scripts/seed.py.j2 +153 -0
  122. moltpy/modules/sentry/manifest.json +9 -0
  123. moltpy/modules/sentry/templates/app/core/sentry.py +30 -0
  124. moltpy/modules/storage/manifest.json +16 -0
  125. moltpy/modules/storage/templates/app/routers/storage.py.j2 +205 -0
  126. moltpy/modules/storage/templates/app/schemas/storage.py.j2 +70 -0
  127. moltpy/modules/storage/templates/app/services/storage.py.j2 +270 -0
  128. moltpy/modules/terraform/manifest.json +16 -0
  129. moltpy/modules/terraform/templates/terraform/main.tf.j2 +527 -0
  130. moltpy/modules/terraform/templates/terraform/outputs.tf.j2 +55 -0
  131. moltpy/modules/terraform/templates/terraform/variables.tf.j2 +52 -0
  132. moltpy/modules/testing/manifest.json +9 -0
  133. moltpy/modules/users/manifest.json +9 -0
  134. moltpy/modules/users/templates/app/models/__init__.py +3 -0
  135. moltpy/modules/users/templates/app/models/user.py +33 -0
  136. moltpy/modules/users/templates/app/routers/users.py +54 -0
  137. moltpy/modules/users/templates/app/schemas/__init__.py +3 -0
  138. moltpy/modules/users/templates/app/schemas/user.py +36 -0
  139. moltpy/modules/users/templates/app/services/__init__.py +3 -0
  140. moltpy/modules/users/templates/app/services/user.py +64 -0
  141. moltpy/modules/websockets/manifest.json +16 -0
  142. moltpy/modules/websockets/templates/app/routers/websockets.py.j2 +168 -0
  143. moltpy/modules/websockets/templates/app/websockets.py.j2 +307 -0
  144. moltpy/presets.py +168 -0
  145. moltpy/registry.py +241 -0
  146. moltpy/renderer.py +421 -0
  147. moltpy/templates/.env.example.j2 +130 -0
  148. moltpy/templates/.gitignore +73 -0
  149. moltpy/templates/CONTRIBUTING.md.j2 +126 -0
  150. moltpy/templates/README.md.j2 +154 -0
  151. moltpy/templates/__init__.py +1 -0
  152. moltpy/templates/app/__init__.py.j2 +3 -0
  153. moltpy/templates/app/config.py.j2 +154 -0
  154. moltpy/templates/app/dependencies.py.j2 +37 -0
  155. moltpy/templates/app/main.py.j2 +223 -0
  156. moltpy/templates/app/routers/__init__.py +1 -0
  157. moltpy/templates/app/routers/example.py +48 -0
  158. moltpy/templates/app/routers/health.py +34 -0
  159. moltpy/templates/app/utils/__init__.py +1 -0
  160. moltpy/templates/app/utils/logging.py +43 -0
  161. moltpy/templates/app/utils/pagination.py +38 -0
  162. moltpy/templates/docs/modules.md.j2 +40 -0
  163. moltpy/templates/pyproject.toml.j2 +159 -0
  164. moltpy/templates/tests/__init__.py +1 -0
  165. moltpy/templates/tests/conftest.py.j2 +67 -0
  166. moltpy/templates/tests/test_example.py +37 -0
  167. moltpy/wizard.py +243 -0
  168. moltpy-0.1.0.dist-info/METADATA +322 -0
  169. moltpy-0.1.0.dist-info/RECORD +172 -0
  170. moltpy-0.1.0.dist-info/WHEEL +4 -0
  171. moltpy-0.1.0.dist-info/entry_points.txt +2 -0
  172. moltpy-0.1.0.dist-info/licenses/LICENSE +21 -0
moltpy/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """MoltPy — Interactive CLI tool for generating production-ready FastAPI projects."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
moltpy/builder.py ADDED
@@ -0,0 +1,341 @@
1
+ """Project builder orchestrates the full generation flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import py_compile
6
+ import re
7
+ import secrets
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+ from rich.progress import Progress, SpinnerColumn, TextColumn
13
+ from rich.table import Table
14
+
15
+ from moltpy.config import ProjectConfig
16
+ from moltpy.registry import ModuleRegistry
17
+ from moltpy.renderer import RenderedFile, TemplateRenderer
18
+
19
+ console = Console()
20
+
21
+
22
+ class ProjectBuilder:
23
+ """Orchestrates project generation from configuration to rendered output."""
24
+
25
+ def __init__(
26
+ self,
27
+ config: ProjectConfig,
28
+ registry: ModuleRegistry | None = None,
29
+ template_dir: Path | None = None,
30
+ ) -> None:
31
+ """Initialize builder with project configuration."""
32
+ self.config = config
33
+ self.registry = registry or ModuleRegistry()
34
+ self.renderer = TemplateRenderer(config, self.registry, template_overrides_dir=template_dir)
35
+
36
+ def build(
37
+ self,
38
+ target_dir: Path | None = None,
39
+ force: bool = False,
40
+ dry_run: bool = False,
41
+ skip_post_generation: bool = False,
42
+ ) -> Path:
43
+ """Build the complete project.
44
+
45
+ Args:
46
+ target_dir: Output directory. Defaults to ./<project_name>
47
+ force: Overwrite existing non-empty directory.
48
+ dry_run: Preview files without writing them.
49
+ skip_post_generation: Skip uv sync, git init, and pre-commit install.
50
+
51
+ Returns:
52
+ Path to the generated project directory.
53
+ """
54
+ if target_dir is None:
55
+ target_dir = Path.cwd() / self.config.name
56
+
57
+ # Check for overwrite
58
+ if target_dir.exists() and any(target_dir.iterdir()) and not force and not dry_run:
59
+ console.print(
60
+ f"[red]Error: Directory '{target_dir}' already exists and is not empty.[/red]"
61
+ )
62
+ console.print("[dim]Use --force to overwrite.[/dim]")
63
+ raise ValueError("Target directory already exists")
64
+
65
+ # Validate module selection
66
+ is_valid, conflicts = self.registry.validate_selection(self.config.modules)
67
+ if not is_valid:
68
+ for msg in conflicts:
69
+ console.print(f"[red]Error: {msg}[/red]")
70
+ raise ValueError("Module selection has conflicts")
71
+
72
+ # Resolve modules and run pre-render hooks
73
+ resolved_modules = self.registry.resolve_dependencies(self.config.modules)
74
+ context = self.renderer._get_context()
75
+ for name in resolved_modules:
76
+ instance = self.registry.get_instance(name)
77
+ if instance is not None:
78
+ try:
79
+ hook_result = instance.pre_render(context)
80
+ except Exception as exc:
81
+ console.print(
82
+ f"[red]Error: Module '{name}' pre_render hook failed: {exc}[/red]"
83
+ )
84
+ raise ValueError(f"Module '{name}' pre_render hook failed: {exc}") from exc
85
+ if hook_result is None:
86
+ raise ValueError(f"Module '{name}' pre_render returned None")
87
+ context = hook_result
88
+
89
+ if dry_run:
90
+ # Dry-run: collect files without writing
91
+ entries = self.renderer.render(target_dir, dry_run=True)
92
+ self._print_dry_run_table(entries, target_dir)
93
+ return target_dir
94
+
95
+ with Progress(
96
+ SpinnerColumn(),
97
+ TextColumn("[progress.description]{task.description}"),
98
+ console=console,
99
+ ) as progress:
100
+ # Step 1: Render templates
101
+ task = progress.add_task("Rendering project templates...", total=None)
102
+ self.renderer.render(target_dir)
103
+ progress.update(task, completed=True)
104
+
105
+ # Step 2: Validate generated Python syntax
106
+ progress.update(task, description="Validating generated code...")
107
+ self._validate_generated_code(target_dir)
108
+
109
+ # Step 3: Generate .env file with secure secrets
110
+ progress.update(task, description="Generating secure .env file...")
111
+ self._generate_env_file(target_dir)
112
+
113
+ # Step 4: Post-generation hooks
114
+ if not skip_post_generation:
115
+ if self.config.post_generation.get("uv_sync", False):
116
+ progress.update(task, description="Running uv sync...")
117
+ self._run_uv_sync(target_dir)
118
+
119
+ if self.config.post_generation.get("git_init", False):
120
+ progress.update(task, description="Initializing git repository...")
121
+ self._run_git_init(target_dir)
122
+
123
+ if self.config.post_generation.get("pre_commit_install", False):
124
+ progress.update(task, description="Installing pre-commit hooks...")
125
+ self._run_pre_commit_install(target_dir)
126
+
127
+ # Step 5: Module post-render hooks
128
+ for name in resolved_modules:
129
+ instance = self.registry.get_instance(name)
130
+ if instance is not None:
131
+ try:
132
+ instance.post_render(target_dir, context)
133
+ except Exception as exc:
134
+ console.print(
135
+ f"[red]Error: Module '{name}' post_render hook failed: {exc}[/red]"
136
+ )
137
+ raise ValueError(f"Module '{name}' post_render hook failed: {exc}") from exc
138
+
139
+ # Summary
140
+ module_count = len(self.registry.resolve_dependencies(self.config.modules))
141
+ console.print()
142
+ console.print(
143
+ f"[green]✅[/green] Project [bold]{self.config.name}[/bold] created at {target_dir}"
144
+ )
145
+ console.print(f" [dim]{module_count} modules included[/dim]")
146
+
147
+ # Operational assets summary
148
+ assets: list[str] = []
149
+ if "email" in self.config.modules:
150
+ assets.append("📧 Email: Responsive HTML templates generated in app/templates/email/")
151
+ if "observability" in self.config.modules and "docker" in self.config.modules:
152
+ assets.append(
153
+ "📊 Metrics: Grafana dashboard configured. Access at http://localhost:3000"
154
+ " (admin/admin)"
155
+ )
156
+ if "openapi" in self.config.modules:
157
+ client_var = self.config.module_variables.get("openapi", {}).get(
158
+ "generate_frontend_client", False
159
+ )
160
+ if client_var:
161
+ assets.append(
162
+ "🔌 Frontend SDK: Run `make generate-client` to generate TypeScript client"
163
+ )
164
+ if "terraform" in self.config.modules:
165
+ assets.append("☁️ Terraform: Infrastructure configuration generated in terraform/")
166
+
167
+ if assets:
168
+ console.print()
169
+ console.print("[bold]Operational Assets Included:[/bold]")
170
+ for asset in assets:
171
+ console.print(f" {asset}")
172
+
173
+ console.print()
174
+ console.print("[bold]Next steps:[/bold]")
175
+ console.print(f" cd {self.config.name}")
176
+ if self.config.post_generation.get("uv_sync", False):
177
+ console.print(" make run-dev")
178
+ else:
179
+ console.print(" uv sync")
180
+ console.print(" make run-dev")
181
+
182
+ return target_dir
183
+
184
+ def _print_dry_run_table(self, entries: list[RenderedFile], target_dir: Path) -> None:
185
+ """Print a preview table of files that would be generated."""
186
+ from moltpy.renderer import RenderedFile
187
+
188
+ table = Table(title=f"[bold]Dry Run: {self.config.name}[/bold]")
189
+ table.add_column("File", style="cyan")
190
+ table.add_column("Size", justify="right", style="dim")
191
+ table.add_column("Source", style="green")
192
+
193
+ total_size = 0
194
+ for entry in entries:
195
+ if isinstance(entry, RenderedFile):
196
+ rel_path = (
197
+ str(entry.path.relative_to(target_dir))
198
+ if entry.path.is_relative_to(target_dir)
199
+ else str(entry.path)
200
+ )
201
+ size_str = (
202
+ f"{entry.size:,} B" if entry.size < 1024 else f"{entry.size / 1024:.1f} KB"
203
+ )
204
+ table.add_row(rel_path, size_str, entry.source)
205
+ total_size += entry.size
206
+
207
+ console.print()
208
+ console.print(table)
209
+ console.print()
210
+ console.print(f"[dim]{len(entries)} files, {total_size:,} bytes total[/dim]")
211
+ console.print("[yellow]⚠️ Dry run — no files were written.[/yellow]")
212
+
213
+ def _validate_generated_code(self, target_dir: Path) -> None:
214
+ """Validate syntax of all generated Python files using py_compile.
215
+
216
+ Raises:
217
+ ValueError: If any generated Python file has invalid syntax.
218
+ """
219
+ errors: list[str] = []
220
+ for py_file in target_dir.rglob("*.py"):
221
+ try:
222
+ py_compile.compile(str(py_file), doraise=True)
223
+ except py_compile.PyCompileError as exc:
224
+ rel = py_file.relative_to(target_dir)
225
+ errors.append(f"{rel}: {exc}")
226
+
227
+ if errors:
228
+ for msg in errors:
229
+ console.print(f"[red]Syntax error: {msg}[/red]")
230
+ raise ValueError(f"Generated code has syntax errors in {len(errors)} file(s)")
231
+
232
+ def _run_uv_sync(self, target_dir: Path) -> None:
233
+ """Run uv sync in the target directory."""
234
+ import os
235
+
236
+ env = os.environ.copy()
237
+ env.pop("VIRTUAL_ENV", None)
238
+ env.pop("PYTHONPATH", None)
239
+
240
+ try:
241
+ subprocess.run(
242
+ ["uv", "sync"],
243
+ cwd=target_dir,
244
+ check=True,
245
+ capture_output=True,
246
+ env=env,
247
+ )
248
+ except (subprocess.CalledProcessError, FileNotFoundError):
249
+ console.print("[yellow]Warning: uv sync failed. Run it manually.[/yellow]")
250
+
251
+ def _run_git_init(self, target_dir: Path) -> None:
252
+ """Initialize git repository in the target directory."""
253
+ try:
254
+ subprocess.run(
255
+ ["git", "init"],
256
+ cwd=target_dir,
257
+ check=True,
258
+ capture_output=True,
259
+ )
260
+ # Create initial commit
261
+ subprocess.run(
262
+ ["git", "add", "."],
263
+ cwd=target_dir,
264
+ check=True,
265
+ capture_output=True,
266
+ )
267
+ subprocess.run(
268
+ ["git", "commit", "-m", "Initial commit from MoltPy"],
269
+ cwd=target_dir,
270
+ check=True,
271
+ capture_output=True,
272
+ )
273
+ except (subprocess.CalledProcessError, FileNotFoundError):
274
+ console.print("[yellow]Warning: git init failed. Run it manually.[/yellow]")
275
+
276
+ def _run_pre_commit_install(self, target_dir: Path) -> None:
277
+ """Install pre-commit hooks in the target directory."""
278
+ import os
279
+
280
+ env = os.environ.copy()
281
+ env.pop("VIRTUAL_ENV", None)
282
+ env.pop("PYTHONPATH", None)
283
+
284
+ try:
285
+ subprocess.run(
286
+ ["uv", "run", "pre-commit", "install"],
287
+ cwd=target_dir,
288
+ check=True,
289
+ capture_output=True,
290
+ env=env,
291
+ )
292
+ except (subprocess.CalledProcessError, FileNotFoundError):
293
+ console.print("[yellow]Warning: pre-commit install failed. Run it manually.[/yellow]")
294
+
295
+ def _generate_env_file(self, target_dir: Path) -> None:
296
+ """Generate .env file from .env.example with secure random values.
297
+
298
+ Reads the rendered .env.example file, replaces placeholder values with
299
+ cryptographically secure random strings, and writes the result to .env.
300
+ Never overwrites an existing .env file.
301
+ """
302
+ env_path = target_dir / ".env"
303
+ env_example_path = target_dir / ".env.example"
304
+
305
+ # Don't overwrite existing .env
306
+ if env_path.exists():
307
+ return
308
+
309
+ if not env_example_path.exists():
310
+ return
311
+
312
+ content = env_example_path.read_text()
313
+
314
+ # Generate secure values
315
+ secret_key = secrets.token_hex(64) # 128 hex characters
316
+ db_password = secrets.token_urlsafe(24) # ~32 characters, URL-safe
317
+
318
+ # Replace known placeholder patterns with generated values
319
+ # SECRET_KEY placeholder
320
+ content = re.sub(
321
+ r"SECRET_KEY=.*",
322
+ f"SECRET_KEY={secret_key}",
323
+ content,
324
+ )
325
+
326
+ # Database password in connection URLs (matches :password@ pattern)
327
+ content = re.sub(
328
+ r"(postgresql\+asyncpg://[^:]+:)[^@]+(@)",
329
+ rf"\g<1>{db_password}\g<2>",
330
+ content,
331
+ )
332
+
333
+ # Standalone POSTGRES_PASSWORD if present
334
+ content = re.sub(
335
+ r"POSTGRES_PASSWORD=.*",
336
+ f"POSTGRES_PASSWORD={db_password}",
337
+ content,
338
+ )
339
+
340
+ env_path.write_text(content)
341
+ console.print(f"[green]✓[/green] Generated .env with secure secrets ({env_path})")