fastapi-spawn 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.
- fastapi_spawn/__init__.py +6 -0
- fastapi_spawn/cli.py +387 -0
- fastapi_spawn/config.py +162 -0
- fastapi_spawn/constants.py +133 -0
- fastapi_spawn/generator.py +294 -0
- fastapi_spawn/interactive.py +192 -0
- fastapi_spawn/templates/alembic/alembic.ini.j2 +39 -0
- fastapi_spawn/templates/alembic/env.py.j2 +64 -0
- fastapi_spawn/templates/app/__init__.py.j2 +1 -0
- fastapi_spawn/templates/app/api/deps.py.j2 +39 -0
- fastapi_spawn/templates/app/api/v1/auth.py.j2 +59 -0
- fastapi_spawn/templates/app/api/v1/health.py.j2 +40 -0
- fastapi_spawn/templates/app/core/ai.py.j2 +76 -0
- fastapi_spawn/templates/app/core/config.py.j2 +177 -0
- fastapi_spawn/templates/app/core/exceptions.py.j2 +43 -0
- fastapi_spawn/templates/app/core/logging.py.j2 +70 -0
- fastapi_spawn/templates/app/core/security.py.j2 +42 -0
- fastapi_spawn/templates/app/core/storage.py.j2 +73 -0
- fastapi_spawn/templates/app/db/session.py.j2 +84 -0
- fastapi_spawn/templates/app/main.py.j2 +71 -0
- fastapi_spawn/templates/base/Makefile.j2 +45 -0
- fastapi_spawn/templates/base/README.md.j2 +74 -0
- fastapi_spawn/templates/base/env.j2 +82 -0
- fastapi_spawn/templates/base/env_example.j2 +85 -0
- fastapi_spawn/templates/base/gitignore.j2 +38 -0
- fastapi_spawn/templates/base/pre_commit.j2 +17 -0
- fastapi_spawn/templates/base/pyproject.toml.j2 +129 -0
- fastapi_spawn/templates/ci/github/publish.yml.j2 +32 -0
- fastapi_spawn/templates/ci/github/tests.yml.j2 +39 -0
- fastapi_spawn/templates/ci/gitlab/gitlab-ci.yml.j2 +29 -0
- fastapi_spawn/templates/docker/Dockerfile.j2 +17 -0
- fastapi_spawn/templates/docker/docker-compose.yml.j2 +97 -0
- fastapi_spawn/templates/docker/dockerignore.j2 +13 -0
- fastapi_spawn/templates/infra/docker/docker-compose.prod.yml.j2 +43 -0
- fastapi_spawn/templates/infra/helm/Chart.yaml.j2 +6 -0
- fastapi_spawn/templates/infra/helm/values.yaml.j2 +26 -0
- fastapi_spawn/templates/infra/terraform/main.tf.j2 +26 -0
- fastapi_spawn/templates/infra/terraform/variables.tf.j2 +17 -0
- fastapi_spawn/templates/root/main.py.j2 +16 -0
- fastapi_spawn/templates/tasks/celery_app.py.j2 +37 -0
- fastapi_spawn/templates/tasks/sample_tasks.py.j2 +27 -0
- fastapi_spawn/templates/tests/conftest.py.j2 +22 -0
- fastapi_spawn/templates/tests/test_health.py.j2 +30 -0
- fastapi_spawn/utils.py +58 -0
- fastapi_spawn/validators.py +67 -0
- fastapi_spawn-0.1.0.dist-info/METADATA +262 -0
- fastapi_spawn-0.1.0.dist-info/RECORD +50 -0
- fastapi_spawn-0.1.0.dist-info/WHEEL +4 -0
- fastapi_spawn-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_spawn-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Core project generation engine for fastapi-spawn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from jinja2 import Environment, PackageLoader, StrictUndefined, TemplateNotFound
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
12
|
+
from rich.tree import Tree
|
|
13
|
+
|
|
14
|
+
from fastapi_spawn.config import ProjectConfig
|
|
15
|
+
from fastapi_spawn.constants import CIProvider, MigrationTool, Stack
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProjectGenerator:
|
|
21
|
+
"""
|
|
22
|
+
Generates a FastAPI project from Jinja2 templates.
|
|
23
|
+
|
|
24
|
+
Final generated structure:
|
|
25
|
+
<project>/
|
|
26
|
+
├── app/
|
|
27
|
+
│ ├── api/v1/ (health, auth?)
|
|
28
|
+
│ ├── core/ (config, logging, exceptions, security?, storage?, ai?)
|
|
29
|
+
│ ├── db/ (session)
|
|
30
|
+
│ ├── models/
|
|
31
|
+
│ ├── schemas/
|
|
32
|
+
│ ├── services/
|
|
33
|
+
│ └── repositories/
|
|
34
|
+
├── tasks/ (celery_app, sample_tasks) — root level
|
|
35
|
+
├── migrations/ (alembic env.py + versions/) — when alembic chosen
|
|
36
|
+
├── infra/
|
|
37
|
+
│ ├── docker/
|
|
38
|
+
│ ├── helm/
|
|
39
|
+
│ └── terraform/
|
|
40
|
+
├── tests/
|
|
41
|
+
├── main.py uv run entry point
|
|
42
|
+
├── alembic.ini when alembic chosen
|
|
43
|
+
├── Dockerfile
|
|
44
|
+
├── docker-compose.yml
|
|
45
|
+
├── .env .env.example .gitignore .pre-commit-config.yaml
|
|
46
|
+
├── Makefile
|
|
47
|
+
└── pyproject.toml
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, config: ProjectConfig, output_dir: Path) -> None:
|
|
51
|
+
self.config = config
|
|
52
|
+
self.output_dir = output_dir
|
|
53
|
+
self.ctx = config.to_context()
|
|
54
|
+
self._env = Environment(
|
|
55
|
+
loader=PackageLoader("fastapi_spawn", "templates"),
|
|
56
|
+
undefined=StrictUndefined,
|
|
57
|
+
keep_trailing_newline=True,
|
|
58
|
+
trim_blocks=True,
|
|
59
|
+
lstrip_blocks=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# ── Public API ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def generate(self) -> Path:
|
|
65
|
+
if self.config.dry_run:
|
|
66
|
+
self._dry_run_display()
|
|
67
|
+
return self.output_dir / self.config.project_name
|
|
68
|
+
|
|
69
|
+
project_path = self.output_dir / self.config.project_name
|
|
70
|
+
|
|
71
|
+
if project_path.exists() and self.config.force:
|
|
72
|
+
shutil.rmtree(project_path)
|
|
73
|
+
|
|
74
|
+
with tempfile.TemporaryDirectory() as staging_root:
|
|
75
|
+
staging = Path(staging_root) / self.config.project_name
|
|
76
|
+
staging.mkdir(parents=True)
|
|
77
|
+
|
|
78
|
+
with Progress(
|
|
79
|
+
SpinnerColumn(),
|
|
80
|
+
TextColumn("[bold cyan]{task.description}"),
|
|
81
|
+
transient=True,
|
|
82
|
+
console=console,
|
|
83
|
+
) as progress:
|
|
84
|
+
task = progress.add_task("Writing root files...", total=None)
|
|
85
|
+
|
|
86
|
+
self._generate_root(staging)
|
|
87
|
+
|
|
88
|
+
progress.update(task, description="Writing app/ module...")
|
|
89
|
+
self._generate_app(staging)
|
|
90
|
+
|
|
91
|
+
if self.config.has_broker:
|
|
92
|
+
progress.update(task, description="Writing tasks/...")
|
|
93
|
+
self._generate_tasks(staging)
|
|
94
|
+
|
|
95
|
+
if self.config.has_alembic:
|
|
96
|
+
progress.update(task, description="Writing Alembic migrations/...")
|
|
97
|
+
self._generate_alembic(staging)
|
|
98
|
+
|
|
99
|
+
if self.config.include_tests:
|
|
100
|
+
progress.update(task, description="Writing tests/...")
|
|
101
|
+
self._generate_tests(staging)
|
|
102
|
+
|
|
103
|
+
if self.config.has_docker:
|
|
104
|
+
progress.update(task, description="Writing Docker files...")
|
|
105
|
+
self._generate_docker(staging)
|
|
106
|
+
|
|
107
|
+
if self.config.has_infra or self.config.stack == Stack.full:
|
|
108
|
+
progress.update(task, description="Writing infra/...")
|
|
109
|
+
self._generate_infra(staging)
|
|
110
|
+
|
|
111
|
+
if self.config.has_ci:
|
|
112
|
+
progress.update(task, description="Writing CI/CD workflows...")
|
|
113
|
+
self._generate_ci(staging)
|
|
114
|
+
|
|
115
|
+
if self.config.include_makefile:
|
|
116
|
+
self._render_to(staging / "Makefile", "base/Makefile.j2")
|
|
117
|
+
|
|
118
|
+
shutil.copytree(str(staging), str(project_path))
|
|
119
|
+
|
|
120
|
+
return project_path
|
|
121
|
+
|
|
122
|
+
# ── Section generators ──────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def _generate_root(self, root: Path) -> None:
|
|
125
|
+
self._render_to(root / "main.py", "root/main.py.j2")
|
|
126
|
+
self._render_to(root / "pyproject.toml", "base/pyproject.toml.j2")
|
|
127
|
+
self._render_to(root / ".env", "base/env.j2")
|
|
128
|
+
self._render_to(root / ".env.example", "base/env_example.j2")
|
|
129
|
+
self._render_to(root / ".gitignore", "base/gitignore.j2")
|
|
130
|
+
self._render_to(root / ".pre-commit-config.yaml", "base/pre_commit.j2")
|
|
131
|
+
self._render_to(root / "README.md", "base/README.md.j2")
|
|
132
|
+
|
|
133
|
+
def _generate_app(self, root: Path) -> None:
|
|
134
|
+
pkg = root / "app"
|
|
135
|
+
pkg.mkdir()
|
|
136
|
+
self._render_to(pkg / "__init__.py", "app/__init__.py.j2")
|
|
137
|
+
self._render_to(pkg / "main.py", "app/main.py.j2")
|
|
138
|
+
|
|
139
|
+
# core/
|
|
140
|
+
core = pkg / "core"
|
|
141
|
+
core.mkdir()
|
|
142
|
+
self._render_to(core / "__init__.py", "app/__init__.py.j2")
|
|
143
|
+
self._render_to(core / "config.py", "app/core/config.py.j2")
|
|
144
|
+
self._render_to(core / "logging.py", "app/core/logging.py.j2")
|
|
145
|
+
self._render_to(core / "exceptions.py", "app/core/exceptions.py.j2")
|
|
146
|
+
if self.config.has_auth:
|
|
147
|
+
self._render_to(core / "security.py", "app/core/security.py.j2")
|
|
148
|
+
if self.config.has_s3:
|
|
149
|
+
self._render_to(core / "storage.py", "app/core/storage.py.j2")
|
|
150
|
+
if self.config.has_ai:
|
|
151
|
+
self._render_to(core / "ai.py", "app/core/ai.py.j2")
|
|
152
|
+
|
|
153
|
+
# api/
|
|
154
|
+
api = pkg / "api"
|
|
155
|
+
api.mkdir()
|
|
156
|
+
self._render_to(api / "__init__.py", "app/__init__.py.j2")
|
|
157
|
+
self._render_to(api / "deps.py", "app/api/deps.py.j2")
|
|
158
|
+
v1 = api / "v1"
|
|
159
|
+
v1.mkdir()
|
|
160
|
+
self._render_to(v1 / "__init__.py", "app/__init__.py.j2")
|
|
161
|
+
self._render_to(v1 / "health.py", "app/api/v1/health.py.j2")
|
|
162
|
+
if self.config.has_auth:
|
|
163
|
+
self._render_to(v1 / "auth.py", "app/api/v1/auth.py.j2")
|
|
164
|
+
|
|
165
|
+
# db/ (only when a real database is chosen)
|
|
166
|
+
if self.config.db.value != "none":
|
|
167
|
+
db_dir = pkg / "db"
|
|
168
|
+
db_dir.mkdir()
|
|
169
|
+
self._render_to(db_dir / "__init__.py", "app/__init__.py.j2")
|
|
170
|
+
self._render_to(db_dir / "session.py", "app/db/session.py.j2")
|
|
171
|
+
|
|
172
|
+
# models/, schemas/, services/, repositories/
|
|
173
|
+
for sub in ("models", "schemas", "services", "repositories"):
|
|
174
|
+
d = pkg / sub
|
|
175
|
+
d.mkdir()
|
|
176
|
+
self._render_to(d / "__init__.py", "app/__init__.py.j2")
|
|
177
|
+
|
|
178
|
+
def _generate_tasks(self, root: Path) -> None:
|
|
179
|
+
"""Root-level tasks/ directory."""
|
|
180
|
+
tasks = root / "tasks"
|
|
181
|
+
tasks.mkdir()
|
|
182
|
+
self._render_to(tasks / "__init__.py", "app/__init__.py.j2")
|
|
183
|
+
self._render_to(tasks / "celery_app.py", "tasks/celery_app.py.j2")
|
|
184
|
+
self._render_to(tasks / "sample_tasks.py", "tasks/sample_tasks.py.j2")
|
|
185
|
+
|
|
186
|
+
def _generate_alembic(self, root: Path) -> None:
|
|
187
|
+
"""Alembic migration setup at project root."""
|
|
188
|
+
self._render_to(root / "alembic.ini", "alembic/alembic.ini.j2")
|
|
189
|
+
migrations = root / "migrations"
|
|
190
|
+
migrations.mkdir()
|
|
191
|
+
(migrations / "versions").mkdir()
|
|
192
|
+
self._render_to(migrations / "env.py", "alembic/env.py.j2")
|
|
193
|
+
self._render_to(migrations / "__init__.py", "app/__init__.py.j2")
|
|
194
|
+
|
|
195
|
+
def _generate_tests(self, root: Path) -> None:
|
|
196
|
+
tests = root / "tests"
|
|
197
|
+
tests.mkdir()
|
|
198
|
+
self._render_to(tests / "__init__.py", "app/__init__.py.j2")
|
|
199
|
+
self._render_to(tests / "conftest.py", "tests/conftest.py.j2")
|
|
200
|
+
self._render_to(tests / "test_health.py", "tests/test_health.py.j2")
|
|
201
|
+
|
|
202
|
+
def _generate_docker(self, root: Path) -> None:
|
|
203
|
+
self._render_to(root / "Dockerfile", "docker/Dockerfile.j2")
|
|
204
|
+
self._render_to(root / "docker-compose.yml", "docker/docker-compose.yml.j2")
|
|
205
|
+
self._render_to(root / ".dockerignore", "docker/dockerignore.j2")
|
|
206
|
+
|
|
207
|
+
def _generate_ci(self, root: Path) -> None:
|
|
208
|
+
ci = self.config.ci
|
|
209
|
+
if ci in (CIProvider.github, CIProvider.both):
|
|
210
|
+
gha = root / ".github" / "workflows"
|
|
211
|
+
gha.mkdir(parents=True)
|
|
212
|
+
self._render_to(gha / "tests.yml", "ci/github/tests.yml.j2")
|
|
213
|
+
self._render_to(gha / "publish.yml", "ci/github/publish.yml.j2")
|
|
214
|
+
if ci in (CIProvider.gitlab, CIProvider.both):
|
|
215
|
+
self._render_to(root / ".gitlab-ci.yml", "ci/gitlab/gitlab-ci.yml.j2")
|
|
216
|
+
|
|
217
|
+
def _generate_infra(self, root: Path) -> None:
|
|
218
|
+
infra = root / "infra"
|
|
219
|
+
(infra / "docker").mkdir(parents=True)
|
|
220
|
+
self._render_to(infra / "docker" / "docker-compose.prod.yml", "infra/docker/docker-compose.prod.yml.j2")
|
|
221
|
+
helm = infra / "helm"
|
|
222
|
+
helm.mkdir()
|
|
223
|
+
self._render_to(helm / "Chart.yaml", "infra/helm/Chart.yaml.j2")
|
|
224
|
+
self._render_to(helm / "values.yaml", "infra/helm/values.yaml.j2")
|
|
225
|
+
tf = infra / "terraform"
|
|
226
|
+
tf.mkdir()
|
|
227
|
+
self._render_to(tf / "main.tf", "infra/terraform/main.tf.j2")
|
|
228
|
+
self._render_to(tf / "variables.tf", "infra/terraform/variables.tf.j2")
|
|
229
|
+
|
|
230
|
+
# ── Render helper ───────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
def _render_to(self, dest: Path, template_name: str) -> None:
|
|
233
|
+
try:
|
|
234
|
+
tmpl = self._env.get_template(template_name)
|
|
235
|
+
except TemplateNotFound:
|
|
236
|
+
return
|
|
237
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
dest.write_text(tmpl.render(**self.ctx), encoding="utf-8")
|
|
239
|
+
|
|
240
|
+
# ── Dry-run display ─────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
def _dry_run_display(self) -> None:
|
|
243
|
+
cfg = self.config
|
|
244
|
+
tree = Tree(
|
|
245
|
+
f":open_file_folder: [bold green]{cfg.project_name}/[/bold green]",
|
|
246
|
+
guide_style="dim",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
app = tree.add("[bold blue]app/[/bold blue]")
|
|
250
|
+
app.add("__init__.py main.py")
|
|
251
|
+
core_items = "config.py logging.py exceptions.py"
|
|
252
|
+
if cfg.has_auth: core_items += " security.py"
|
|
253
|
+
if cfg.has_s3: core_items += " storage.py"
|
|
254
|
+
if cfg.has_ai: core_items += " ai.py"
|
|
255
|
+
app.add(f"[blue]core/[/blue] {core_items}")
|
|
256
|
+
api = app.add("[blue]api/[/blue] deps.py")
|
|
257
|
+
v1 = "health.py" + (" auth.py" if cfg.has_auth else "")
|
|
258
|
+
api.add(f"[blue]v1/[/blue] {v1}")
|
|
259
|
+
if cfg.db.value != "none":
|
|
260
|
+
app.add("[blue]db/[/blue] session.py")
|
|
261
|
+
for sub in ("models/", "schemas/", "services/", "repositories/"):
|
|
262
|
+
app.add(sub)
|
|
263
|
+
|
|
264
|
+
if cfg.has_broker:
|
|
265
|
+
t = tree.add("[blue]tasks/[/blue]")
|
|
266
|
+
t.add("celery_app.py sample_tasks.py")
|
|
267
|
+
|
|
268
|
+
if cfg.has_alembic:
|
|
269
|
+
m = tree.add("[blue]migrations/[/blue]")
|
|
270
|
+
m.add("env.py [dim]versions/[/dim]")
|
|
271
|
+
tree.add("alembic.ini")
|
|
272
|
+
|
|
273
|
+
if cfg.include_tests:
|
|
274
|
+
t = tree.add("[blue]tests/[/blue]")
|
|
275
|
+
t.add("conftest.py test_health.py")
|
|
276
|
+
|
|
277
|
+
if cfg.has_infra or cfg.stack == Stack.full:
|
|
278
|
+
infra = tree.add("[blue]infra/[/blue]")
|
|
279
|
+
infra.add("[blue]docker/[/blue] docker-compose.prod.yml")
|
|
280
|
+
infra.add("[blue]helm/[/blue] Chart.yaml values.yaml")
|
|
281
|
+
infra.add("[blue]terraform/[/blue] main.tf variables.tf")
|
|
282
|
+
|
|
283
|
+
if cfg.has_ci and cfg.ci in (CIProvider.github, CIProvider.both):
|
|
284
|
+
ci = tree.add("[blue].github/workflows/[/blue]")
|
|
285
|
+
ci.add("tests.yml publish.yml")
|
|
286
|
+
|
|
287
|
+
root_files = "main.py pyproject.toml .env .env.example .gitignore .pre-commit-config.yaml README.md"
|
|
288
|
+
if cfg.has_docker:
|
|
289
|
+
root_files += " Dockerfile docker-compose.yml .dockerignore"
|
|
290
|
+
if cfg.include_makefile:
|
|
291
|
+
root_files += " Makefile"
|
|
292
|
+
tree.add(root_files)
|
|
293
|
+
|
|
294
|
+
console.print(tree)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Interactive TUI prompts using questionary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import questionary
|
|
6
|
+
from questionary import Style
|
|
7
|
+
|
|
8
|
+
from fastapi_spawn.constants import (
|
|
9
|
+
AUTH_LABELS,
|
|
10
|
+
BROKER_LABELS,
|
|
11
|
+
DB_LABELS,
|
|
12
|
+
STACK_DESCRIPTIONS,
|
|
13
|
+
AuthType,
|
|
14
|
+
Broker,
|
|
15
|
+
Cache,
|
|
16
|
+
CIProvider,
|
|
17
|
+
Database,
|
|
18
|
+
LogLibrary,
|
|
19
|
+
ORM,
|
|
20
|
+
ORM_DB_COMPAT,
|
|
21
|
+
Stack,
|
|
22
|
+
)
|
|
23
|
+
from fastapi_spawn.validators import questionary_validator, validate_project_name
|
|
24
|
+
|
|
25
|
+
SPAWN_STYLE = Style(
|
|
26
|
+
[
|
|
27
|
+
("qmark", "fg:#7c3aed bold"),
|
|
28
|
+
("question", "bold"),
|
|
29
|
+
("answer", "fg:#22d3ee bold"),
|
|
30
|
+
("pointer", "fg:#7c3aed bold"),
|
|
31
|
+
("highlighted", "fg:#7c3aed bold"),
|
|
32
|
+
("selected", "fg:#22d3ee"),
|
|
33
|
+
("separator", "fg:#6b7280"),
|
|
34
|
+
("instruction", "fg:#6b7280"),
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def prompt_project_name(default: str = "") -> str:
|
|
40
|
+
return questionary.text(
|
|
41
|
+
"Project name:",
|
|
42
|
+
default=default,
|
|
43
|
+
validate=questionary_validator(validate_project_name),
|
|
44
|
+
style=SPAWN_STYLE,
|
|
45
|
+
).unsafe_ask()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def prompt_database() -> Database:
|
|
49
|
+
choices = [
|
|
50
|
+
questionary.Choice(title=label, value=db)
|
|
51
|
+
for db, label in DB_LABELS.items()
|
|
52
|
+
]
|
|
53
|
+
return questionary.select(
|
|
54
|
+
"Database backend:",
|
|
55
|
+
choices=choices,
|
|
56
|
+
style=SPAWN_STYLE,
|
|
57
|
+
).unsafe_ask()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def prompt_orm(db: Database) -> ORM:
|
|
61
|
+
compatible = ORM_DB_COMPAT.get(ORM.none, [])
|
|
62
|
+
valid_orms = [
|
|
63
|
+
orm for orm in ORM
|
|
64
|
+
if db in ORM_DB_COMPAT.get(orm, []) or orm == ORM.none
|
|
65
|
+
]
|
|
66
|
+
# Filter ORMs truly compatible with the chosen DB
|
|
67
|
+
valid_orms = [
|
|
68
|
+
orm for orm in ORM
|
|
69
|
+
if db in ORM_DB_COMPAT.get(orm, compatible)
|
|
70
|
+
]
|
|
71
|
+
choices = [questionary.Choice(title=o.value, value=o) for o in valid_orms]
|
|
72
|
+
if not choices:
|
|
73
|
+
return ORM.none
|
|
74
|
+
return questionary.select(
|
|
75
|
+
"ORM / ODM:",
|
|
76
|
+
choices=choices,
|
|
77
|
+
style=SPAWN_STYLE,
|
|
78
|
+
).unsafe_ask()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def prompt_auth() -> AuthType:
|
|
82
|
+
choices = [
|
|
83
|
+
questionary.Choice(title=label, value=auth)
|
|
84
|
+
for auth, label in AUTH_LABELS.items()
|
|
85
|
+
]
|
|
86
|
+
return questionary.select(
|
|
87
|
+
"Authentication strategy:",
|
|
88
|
+
choices=choices,
|
|
89
|
+
style=SPAWN_STYLE,
|
|
90
|
+
).unsafe_ask()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def prompt_broker() -> Broker:
|
|
94
|
+
choices = [
|
|
95
|
+
questionary.Choice(title=label, value=broker)
|
|
96
|
+
for broker, label in BROKER_LABELS.items()
|
|
97
|
+
]
|
|
98
|
+
return questionary.select(
|
|
99
|
+
"Message broker:",
|
|
100
|
+
choices=choices,
|
|
101
|
+
style=SPAWN_STYLE,
|
|
102
|
+
).unsafe_ask()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def prompt_cache() -> Cache:
|
|
106
|
+
choices = [
|
|
107
|
+
questionary.Choice(title=c.value, value=c)
|
|
108
|
+
for c in Cache
|
|
109
|
+
]
|
|
110
|
+
return questionary.select(
|
|
111
|
+
"Cache layer:",
|
|
112
|
+
choices=choices,
|
|
113
|
+
style=SPAWN_STYLE,
|
|
114
|
+
).unsafe_ask()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def prompt_stack() -> Stack:
|
|
118
|
+
choices = [
|
|
119
|
+
questionary.Choice(title=f"{s.value} — {STACK_DESCRIPTIONS[s]}", value=s)
|
|
120
|
+
for s in Stack
|
|
121
|
+
]
|
|
122
|
+
return questionary.select(
|
|
123
|
+
"Deployment stack:",
|
|
124
|
+
choices=choices,
|
|
125
|
+
style=SPAWN_STYLE,
|
|
126
|
+
).unsafe_ask()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def prompt_ci() -> CIProvider:
|
|
130
|
+
choices = [
|
|
131
|
+
questionary.Choice(title=c.value, value=c)
|
|
132
|
+
for c in CIProvider
|
|
133
|
+
]
|
|
134
|
+
return questionary.select(
|
|
135
|
+
"CI/CD provider:",
|
|
136
|
+
choices=choices,
|
|
137
|
+
style=SPAWN_STYLE,
|
|
138
|
+
).unsafe_ask()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def prompt_log_lib() -> LogLibrary:
|
|
142
|
+
choices = [
|
|
143
|
+
questionary.Choice(title=l.value, value=l)
|
|
144
|
+
for l in LogLibrary
|
|
145
|
+
]
|
|
146
|
+
return questionary.select(
|
|
147
|
+
"Logging library:",
|
|
148
|
+
choices=choices,
|
|
149
|
+
style=SPAWN_STYLE,
|
|
150
|
+
).unsafe_ask()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def prompt_flags() -> tuple[bool, bool]:
|
|
154
|
+
"""Returns (include_docker, include_tests)."""
|
|
155
|
+
include_docker = questionary.confirm(
|
|
156
|
+
"Include Docker files?", default=True, style=SPAWN_STYLE
|
|
157
|
+
).unsafe_ask()
|
|
158
|
+
include_tests = questionary.confirm(
|
|
159
|
+
"Include test suite?", default=True, style=SPAWN_STYLE
|
|
160
|
+
).unsafe_ask()
|
|
161
|
+
return include_docker, include_tests
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def run_interactive_flow(project_name: str = "") -> dict:
|
|
165
|
+
"""
|
|
166
|
+
Run the full interactive TUI and return a dict of selected options
|
|
167
|
+
suitable for passing to ProjectConfig.
|
|
168
|
+
"""
|
|
169
|
+
name = prompt_project_name(project_name)
|
|
170
|
+
db = prompt_database()
|
|
171
|
+
orm = prompt_orm(db)
|
|
172
|
+
auth = prompt_auth()
|
|
173
|
+
broker = prompt_broker()
|
|
174
|
+
cache = prompt_cache()
|
|
175
|
+
stack = prompt_stack()
|
|
176
|
+
ci = prompt_ci()
|
|
177
|
+
log_lib = prompt_log_lib()
|
|
178
|
+
include_docker, include_tests = prompt_flags()
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
"project_name": name,
|
|
182
|
+
"db": db,
|
|
183
|
+
"orm": orm,
|
|
184
|
+
"auth": auth,
|
|
185
|
+
"broker": broker,
|
|
186
|
+
"cache": cache,
|
|
187
|
+
"stack": stack,
|
|
188
|
+
"ci": ci,
|
|
189
|
+
"log_lib": log_lib,
|
|
190
|
+
"include_docker": include_docker,
|
|
191
|
+
"include_tests": include_tests,
|
|
192
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = migrations
|
|
3
|
+
prepend_sys_path = .
|
|
4
|
+
version_path_separator = os
|
|
5
|
+
sqlalchemy.url =
|
|
6
|
+
|
|
7
|
+
[loggers]
|
|
8
|
+
keys = root,sqlalchemy,alembic
|
|
9
|
+
|
|
10
|
+
[handlers]
|
|
11
|
+
keys = console
|
|
12
|
+
|
|
13
|
+
[formatters]
|
|
14
|
+
keys = generic
|
|
15
|
+
|
|
16
|
+
[logger_root]
|
|
17
|
+
level = WARN
|
|
18
|
+
handlers = console
|
|
19
|
+
qualname =
|
|
20
|
+
|
|
21
|
+
[logger_sqlalchemy]
|
|
22
|
+
level = WARN
|
|
23
|
+
handlers =
|
|
24
|
+
qualname = sqlalchemy.engine
|
|
25
|
+
|
|
26
|
+
[logger_alembic]
|
|
27
|
+
level = INFO
|
|
28
|
+
handlers =
|
|
29
|
+
qualname = alembic
|
|
30
|
+
|
|
31
|
+
[handler_console]
|
|
32
|
+
class = StreamHandler
|
|
33
|
+
args = (sys.stderr,)
|
|
34
|
+
level = NOTSET
|
|
35
|
+
formatter = generic
|
|
36
|
+
|
|
37
|
+
[formatter_generic]
|
|
38
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
39
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Alembic environment configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from logging.config import fileConfig
|
|
7
|
+
|
|
8
|
+
from alembic import context
|
|
9
|
+
from sqlalchemy import pool
|
|
10
|
+
from sqlalchemy.engine import Connection
|
|
11
|
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
12
|
+
|
|
13
|
+
from app.core.config import settings
|
|
14
|
+
from app.db.session import Base
|
|
15
|
+
|
|
16
|
+
# Import all models so Alembic picks them up for autogenerate
|
|
17
|
+
# from app.models import user # noqa: F401
|
|
18
|
+
|
|
19
|
+
config = context.config
|
|
20
|
+
config.set_main_option("sqlalchemy.url", settings.database_url)
|
|
21
|
+
|
|
22
|
+
if config.config_file_name is not None:
|
|
23
|
+
fileConfig(config.config_file_name)
|
|
24
|
+
|
|
25
|
+
target_metadata = Base.metadata
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_migrations_offline() -> None:
|
|
29
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
30
|
+
context.configure(
|
|
31
|
+
url=url,
|
|
32
|
+
target_metadata=target_metadata,
|
|
33
|
+
literal_binds=True,
|
|
34
|
+
dialect_opts={"paramstyle": "named"},
|
|
35
|
+
)
|
|
36
|
+
with context.begin_transaction():
|
|
37
|
+
context.run_migrations()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def do_run_migrations(connection: Connection) -> None:
|
|
41
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
42
|
+
with context.begin_transaction():
|
|
43
|
+
context.run_migrations()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def run_async_migrations() -> None:
|
|
47
|
+
connectable = async_engine_from_config(
|
|
48
|
+
config.get_section(config.config_ini_section, {}),
|
|
49
|
+
prefix="sqlalchemy.",
|
|
50
|
+
poolclass=pool.NullPool,
|
|
51
|
+
)
|
|
52
|
+
async with connectable.connect() as connection:
|
|
53
|
+
await connection.run_sync(do_run_migrations)
|
|
54
|
+
await connectable.dispose()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_migrations_online() -> None:
|
|
58
|
+
asyncio.run(run_async_migrations())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if context.is_offline_mode():
|
|
62
|
+
run_migrations_offline()
|
|
63
|
+
else:
|
|
64
|
+
run_migrations_online()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Empty __init__.py"""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""FastAPI dependency injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
{% if has_relational_db and orm == "sqlalchemy" %}
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from app.db.session import get_session
|
|
11
|
+
|
|
12
|
+
DBSession = Annotated[AsyncSession, Depends(get_session)]
|
|
13
|
+
{% endif %}
|
|
14
|
+
{% if has_auth %}
|
|
15
|
+
from fastapi import Depends, HTTPException, status
|
|
16
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
17
|
+
|
|
18
|
+
from app.core.security import decode_token
|
|
19
|
+
|
|
20
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> dict:
|
|
24
|
+
"""Decode JWT and return the payload. Raise 401 on failure."""
|
|
25
|
+
try:
|
|
26
|
+
payload = decode_token(token)
|
|
27
|
+
return payload
|
|
28
|
+
except ValueError as exc:
|
|
29
|
+
raise HTTPException(
|
|
30
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
31
|
+
detail=str(exc),
|
|
32
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
33
|
+
) from exc
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
CurrentUser = Annotated[dict, Depends(get_current_user)]
|
|
37
|
+
{% else %}
|
|
38
|
+
from fastapi import Depends
|
|
39
|
+
{% endif %}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Auth endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
6
|
+
from fastapi.security import OAuth2PasswordRequestForm
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
from app.core.security import create_access_token, create_refresh_token
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/auth")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TokenResponse(BaseModel):
|
|
16
|
+
access_token: str
|
|
17
|
+
refresh_token: str
|
|
18
|
+
token_type: str = "bearer"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post("/login", response_model=TokenResponse, summary="Obtain access token")
|
|
22
|
+
async def login(
|
|
23
|
+
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
|
24
|
+
) -> TokenResponse:
|
|
25
|
+
"""
|
|
26
|
+
Exchange username + password for JWT tokens.
|
|
27
|
+
Replace the stub validation below with real user lookup.
|
|
28
|
+
"""
|
|
29
|
+
# TODO: Replace with real user lookup from DB
|
|
30
|
+
if form_data.username != "admin" or form_data.password != "secret":
|
|
31
|
+
raise HTTPException(
|
|
32
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
33
|
+
detail="Incorrect username or password.",
|
|
34
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
35
|
+
)
|
|
36
|
+
return TokenResponse(
|
|
37
|
+
access_token=create_access_token(subject=form_data.username),
|
|
38
|
+
refresh_token=create_refresh_token(subject=form_data.username),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("/refresh", response_model=TokenResponse, summary="Refresh access token")
|
|
43
|
+
async def refresh(refresh_token: str) -> TokenResponse:
|
|
44
|
+
"""Exchange a valid refresh token for a new access token."""
|
|
45
|
+
from app.core.security import decode_token
|
|
46
|
+
try:
|
|
47
|
+
payload = decode_token(refresh_token)
|
|
48
|
+
if payload.get("type") != "refresh":
|
|
49
|
+
raise ValueError("Not a refresh token.")
|
|
50
|
+
subject = payload["sub"]
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
raise HTTPException(
|
|
53
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
54
|
+
detail=str(exc),
|
|
55
|
+
) from exc
|
|
56
|
+
return TokenResponse(
|
|
57
|
+
access_token=create_access_token(subject=subject),
|
|
58
|
+
refresh_token=create_refresh_token(subject=subject),
|
|
59
|
+
)
|