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.
Files changed (50) hide show
  1. fastapi_spawn/__init__.py +6 -0
  2. fastapi_spawn/cli.py +387 -0
  3. fastapi_spawn/config.py +162 -0
  4. fastapi_spawn/constants.py +133 -0
  5. fastapi_spawn/generator.py +294 -0
  6. fastapi_spawn/interactive.py +192 -0
  7. fastapi_spawn/templates/alembic/alembic.ini.j2 +39 -0
  8. fastapi_spawn/templates/alembic/env.py.j2 +64 -0
  9. fastapi_spawn/templates/app/__init__.py.j2 +1 -0
  10. fastapi_spawn/templates/app/api/deps.py.j2 +39 -0
  11. fastapi_spawn/templates/app/api/v1/auth.py.j2 +59 -0
  12. fastapi_spawn/templates/app/api/v1/health.py.j2 +40 -0
  13. fastapi_spawn/templates/app/core/ai.py.j2 +76 -0
  14. fastapi_spawn/templates/app/core/config.py.j2 +177 -0
  15. fastapi_spawn/templates/app/core/exceptions.py.j2 +43 -0
  16. fastapi_spawn/templates/app/core/logging.py.j2 +70 -0
  17. fastapi_spawn/templates/app/core/security.py.j2 +42 -0
  18. fastapi_spawn/templates/app/core/storage.py.j2 +73 -0
  19. fastapi_spawn/templates/app/db/session.py.j2 +84 -0
  20. fastapi_spawn/templates/app/main.py.j2 +71 -0
  21. fastapi_spawn/templates/base/Makefile.j2 +45 -0
  22. fastapi_spawn/templates/base/README.md.j2 +74 -0
  23. fastapi_spawn/templates/base/env.j2 +82 -0
  24. fastapi_spawn/templates/base/env_example.j2 +85 -0
  25. fastapi_spawn/templates/base/gitignore.j2 +38 -0
  26. fastapi_spawn/templates/base/pre_commit.j2 +17 -0
  27. fastapi_spawn/templates/base/pyproject.toml.j2 +129 -0
  28. fastapi_spawn/templates/ci/github/publish.yml.j2 +32 -0
  29. fastapi_spawn/templates/ci/github/tests.yml.j2 +39 -0
  30. fastapi_spawn/templates/ci/gitlab/gitlab-ci.yml.j2 +29 -0
  31. fastapi_spawn/templates/docker/Dockerfile.j2 +17 -0
  32. fastapi_spawn/templates/docker/docker-compose.yml.j2 +97 -0
  33. fastapi_spawn/templates/docker/dockerignore.j2 +13 -0
  34. fastapi_spawn/templates/infra/docker/docker-compose.prod.yml.j2 +43 -0
  35. fastapi_spawn/templates/infra/helm/Chart.yaml.j2 +6 -0
  36. fastapi_spawn/templates/infra/helm/values.yaml.j2 +26 -0
  37. fastapi_spawn/templates/infra/terraform/main.tf.j2 +26 -0
  38. fastapi_spawn/templates/infra/terraform/variables.tf.j2 +17 -0
  39. fastapi_spawn/templates/root/main.py.j2 +16 -0
  40. fastapi_spawn/templates/tasks/celery_app.py.j2 +37 -0
  41. fastapi_spawn/templates/tasks/sample_tasks.py.j2 +27 -0
  42. fastapi_spawn/templates/tests/conftest.py.j2 +22 -0
  43. fastapi_spawn/templates/tests/test_health.py.j2 +30 -0
  44. fastapi_spawn/utils.py +58 -0
  45. fastapi_spawn/validators.py +67 -0
  46. fastapi_spawn-0.1.0.dist-info/METADATA +262 -0
  47. fastapi_spawn-0.1.0.dist-info/RECORD +50 -0
  48. fastapi_spawn-0.1.0.dist-info/WHEEL +4 -0
  49. fastapi_spawn-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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
+ )