inifastapi 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 (54) hide show
  1. inifastapi/__init__.py +7 -0
  2. inifastapi/__main__.py +5 -0
  3. inifastapi/bootstrap.py +94 -0
  4. inifastapi/cli.py +173 -0
  5. inifastapi/options.py +134 -0
  6. inifastapi/scaffold.py +153 -0
  7. inifastapi/templates/auth/dependencies_auth.py.j2 +19 -0
  8. inifastapi/templates/auth/routes_auth.py.j2 +26 -0
  9. inifastapi/templates/auth/schemas_auth.py.j2 +10 -0
  10. inifastapi/templates/auth/security.py.j2 +21 -0
  11. inifastapi/templates/cache/cache.py.j2 +9 -0
  12. inifastapi/templates/ci/github.yml.j2 +20 -0
  13. inifastapi/templates/ci/gitlab-ci.yml.j2 +11 -0
  14. inifastapi/templates/database/alembic.ini.j2 +35 -0
  15. inifastapi/templates/database/alembic_env.py.j2 +58 -0
  16. inifastapi/templates/database/alembic_readme.py.j2 +1 -0
  17. inifastapi/templates/database/alembic_script.py.mako.j2 +14 -0
  18. inifastapi/templates/database/base.py.j2 +7 -0
  19. inifastapi/templates/database/db_init.py.j2 +1 -0
  20. inifastapi/templates/database/session.py.j2 +14 -0
  21. inifastapi/templates/docker/.dockerignore.j2 +6 -0
  22. inifastapi/templates/docker/Dockerfile.j2 +15 -0
  23. inifastapi/templates/docker/docker-compose.yml.j2 +47 -0
  24. inifastapi/templates/project/.env.example.j2 +31 -0
  25. inifastapi/templates/project/.gitignore.j2 +10 -0
  26. inifastapi/templates/project/.python-version.j2 +1 -0
  27. inifastapi/templates/project/README.md.j2 +47 -0
  28. inifastapi/templates/project/api_init.py.j2 +1 -0
  29. inifastapi/templates/project/api_router.py.j2 +13 -0
  30. inifastapi/templates/project/api_routes_init.py.j2 +1 -0
  31. inifastapi/templates/project/config.py.j2 +45 -0
  32. inifastapi/templates/project/core_init.py.j2 +1 -0
  33. inifastapi/templates/project/dependencies_init.py.j2 +1 -0
  34. inifastapi/templates/project/lifespan.py.j2 +25 -0
  35. inifastapi/templates/project/logging.py.j2 +15 -0
  36. inifastapi/templates/project/main.py.j2 +32 -0
  37. inifastapi/templates/project/middleware.py.j2 +51 -0
  38. inifastapi/templates/project/models_init.py.j2 +1 -0
  39. inifastapi/templates/project/package_init.py.j2 +1 -0
  40. inifastapi/templates/project/pyproject.toml.j2 +18 -0
  41. inifastapi/templates/project/pyrightconfig.json.j2 +15 -0
  42. inifastapi/templates/project/routes_health.py.j2 +9 -0
  43. inifastapi/templates/project/schemas_init.py.j2 +1 -0
  44. inifastapi/templates/project/services_init.py.j2 +1 -0
  45. inifastapi/templates/project/tests_health.py.j2 +24 -0
  46. inifastapi/templates/project/vscode_settings.json.j2 +13 -0
  47. inifastapi/templates/task_queue/celery_app.py.j2 +7 -0
  48. inifastapi/templates/task_queue/routes_tasks.py.j2 +15 -0
  49. inifastapi/templates/task_queue/sample_task.py.j2 +6 -0
  50. inifastapi/templates/task_queue/tasks_init.py.j2 +1 -0
  51. inifastapi-0.1.0.dist-info/METADATA +85 -0
  52. inifastapi-0.1.0.dist-info/RECORD +54 -0
  53. inifastapi-0.1.0.dist-info/WHEEL +4 -0
  54. inifastapi-0.1.0.dist-info/entry_points.txt +2 -0
inifastapi/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """inifastapi package."""
2
+
3
+ from .bootstrap import BootstrapError, bootstrap_project
4
+ from .options import ProjectOptions
5
+ from .scaffold import build_plan, scaffold_project
6
+
7
+ __all__ = ["BootstrapError", "ProjectOptions", "bootstrap_project", "build_plan", "scaffold_project"]
inifastapi/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import app
2
+
3
+
4
+ if __name__ == "__main__":
5
+ app()
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import shutil
5
+ import subprocess
6
+
7
+ from .options import ProjectOptions
8
+
9
+
10
+ BASE_DEPENDENCIES = [
11
+ "fastapi[standard]",
12
+ "pydantic-settings",
13
+ ]
14
+
15
+ AUTH_DEPENDENCIES = [
16
+ "python-jose[cryptography]",
17
+ "passlib[bcrypt]",
18
+ "python-multipart",
19
+ ]
20
+
21
+ DATABASE_DEPENDENCIES = [
22
+ "sqlalchemy[asyncio]",
23
+ "alembic",
24
+ "asyncpg",
25
+ ]
26
+
27
+ CACHE_DEPENDENCIES = [
28
+ "redis[hiredis]",
29
+ ]
30
+
31
+ TASK_QUEUE_DEPENDENCIES = [
32
+ "celery",
33
+ ]
34
+
35
+ DEV_DEPENDENCIES = [
36
+ "coverage>=7.13.5",
37
+ "prek>=0.3.8",
38
+ "pytest>=9.0.2",
39
+ "pytest-alembic>=0.12.1",
40
+ "pytest-asyncio>=1.3.0",
41
+ "pytest-cov>=7.1.0",
42
+ "ruff>=0.15.9",
43
+ ]
44
+
45
+
46
+ class BootstrapError(RuntimeError):
47
+ """Raised when project environment bootstrap cannot be completed."""
48
+
49
+
50
+ def runtime_dependencies(options: ProjectOptions) -> list[str]:
51
+ dependencies = list(BASE_DEPENDENCIES)
52
+ if options.auth_enabled:
53
+ dependencies.extend(AUTH_DEPENDENCIES)
54
+ if options.database_enabled:
55
+ dependencies.extend(DATABASE_DEPENDENCIES)
56
+ if options.cache_enabled:
57
+ dependencies.extend(CACHE_DEPENDENCIES)
58
+ if options.task_queue_enabled:
59
+ dependencies.extend(TASK_QUEUE_DEPENDENCIES)
60
+ return dependencies
61
+
62
+
63
+ def dev_dependencies() -> list[str]:
64
+ return list(DEV_DEPENDENCIES)
65
+
66
+
67
+ def bootstrap_project(options: ProjectOptions) -> None:
68
+ if shutil.which("uv") is None:
69
+ raise BootstrapError(
70
+ "Generated project files, but could not bootstrap the environment because `uv` "
71
+ "is not installed. Install `uv`, then run `uv sync` in "
72
+ f"'{options.target_dir}'. For one-off usage, prefer "
73
+ f"`uvx inifastapi init {options.project_name}`."
74
+ )
75
+
76
+ runtime = runtime_dependencies(options)
77
+ if runtime:
78
+ run_uv(["uv", "add", "--no-sync", *runtime], cwd=options.target_dir)
79
+
80
+ dev = dev_dependencies()
81
+ if dev:
82
+ run_uv(["uv", "add", "--dev", "--no-sync", *dev], cwd=options.target_dir)
83
+
84
+ run_uv(["uv", "sync"], cwd=options.target_dir)
85
+
86
+
87
+ def run_uv(command: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]:
88
+ return subprocess.run(
89
+ command,
90
+ cwd=cwd,
91
+ check=True,
92
+ text=True,
93
+ capture_output=True,
94
+ )
inifastapi/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from .bootstrap import BootstrapError
9
+ from .options import ProjectOptions, normalize_package_name
10
+ from .scaffold import build_plan, scaffold_project
11
+
12
+
13
+ app = typer.Typer(help="Initialize a production-oriented FastAPI project.", no_args_is_help=True)
14
+
15
+
16
+ @app.callback()
17
+ def main() -> None:
18
+ """CLI entrypoint."""
19
+
20
+
21
+ def _prompt_text(value: Optional[str], message: str, default: str) -> str:
22
+ if value:
23
+ return value
24
+ return typer.prompt(message, default=default)
25
+
26
+
27
+ def _build_options(
28
+ project_name: str,
29
+ package_name: Optional[str],
30
+ target_dir: Optional[Path],
31
+ app_name: Optional[str],
32
+ description: Optional[str],
33
+ python_version: str,
34
+ host: str,
35
+ port: int,
36
+ api_prefix: str,
37
+ docs_enabled: Optional[bool],
38
+ cors_enabled: Optional[bool],
39
+ tests_enabled: Optional[bool],
40
+ docker_enabled: Optional[bool],
41
+ ci_enabled: Optional[bool],
42
+ auth_enabled: Optional[bool],
43
+ database_enabled: Optional[bool],
44
+ cache_enabled: Optional[bool],
45
+ task_queue_enabled: Optional[bool],
46
+ layout: Optional[str],
47
+ ci_provider: Optional[str],
48
+ force: bool,
49
+ dry_run: bool,
50
+ bootstrap_env: bool,
51
+ ) -> ProjectOptions:
52
+ suggested_package = normalize_package_name(package_name or "app")
53
+ chosen_package = _prompt_text(package_name, "Python package name", suggested_package)
54
+ chosen_target = target_dir or Path(
55
+ _prompt_text(None, "Target directory", str(Path.cwd() / project_name))
56
+ )
57
+ chosen_layout = layout or typer.prompt("Project layout", default="app")
58
+ chosen_app_name = _prompt_text(app_name, "Application title", project_name.replace("-", " ").title())
59
+ chosen_description = _prompt_text(description, "Project description", f"{chosen_app_name} service")
60
+ chosen_docs_enabled = True if docs_enabled is None else docs_enabled
61
+ chosen_cors_enabled = True if cors_enabled is None else cors_enabled
62
+ chosen_tests_enabled = True if tests_enabled is None else tests_enabled
63
+ chosen_database_enabled = False if database_enabled is None else database_enabled
64
+ chosen_auth_enabled = False if auth_enabled is None else auth_enabled
65
+ chosen_cache_enabled = False if cache_enabled is None else cache_enabled
66
+ chosen_task_queue_enabled = False if task_queue_enabled is None else task_queue_enabled
67
+ chosen_docker_enabled = False if docker_enabled is None else docker_enabled
68
+ chosen_ci_enabled = False if ci_enabled is None else ci_enabled
69
+ chosen_ci_provider = ci_provider or "github"
70
+ if chosen_ci_enabled and ci_provider is None and not dry_run:
71
+ chosen_ci_provider = typer.prompt("CI provider", default="github")
72
+
73
+ return ProjectOptions(
74
+ project_name=project_name,
75
+ package_name=chosen_package,
76
+ target_dir=chosen_target,
77
+ app_name=chosen_app_name,
78
+ description=chosen_description,
79
+ python_version=python_version,
80
+ host=host,
81
+ port=port,
82
+ api_prefix=api_prefix,
83
+ docs_enabled=chosen_docs_enabled,
84
+ cors_enabled=chosen_cors_enabled,
85
+ tests_enabled=chosen_tests_enabled,
86
+ docker_enabled=chosen_docker_enabled,
87
+ ci_enabled=chosen_ci_enabled,
88
+ auth_enabled=chosen_auth_enabled,
89
+ database_enabled=chosen_database_enabled,
90
+ cache_enabled=chosen_cache_enabled,
91
+ task_queue_enabled=chosen_task_queue_enabled,
92
+ layout=chosen_layout,
93
+ ci_provider=chosen_ci_provider,
94
+ force=force,
95
+ dry_run=dry_run,
96
+ bootstrap_env=bootstrap_env,
97
+ )
98
+
99
+
100
+ @app.command()
101
+ def init(
102
+ project_name: str = typer.Argument(..., help="Distribution name for the generated project."),
103
+ package_name: Optional[str] = typer.Option(None, "--package-name", help="Python package name."),
104
+ target_dir: Optional[Path] = typer.Option(None, "--target-dir", help="Directory to generate into."),
105
+ app_name: Optional[str] = typer.Option(None, "--app-name", help="Human-readable app name."),
106
+ description: Optional[str] = typer.Option(None, "--description", help="Project description."),
107
+ python_version: str = typer.Option("3.12.10", "--python-version", help="Python version."),
108
+ host: str = typer.Option("0.0.0.0", "--host", help="Default host."),
109
+ port: int = typer.Option(8000, "--port", help="Default port."),
110
+ api_prefix: str = typer.Option("/api/v1", "--api-prefix", help="Global API prefix."),
111
+ docs_enabled: Optional[bool] = typer.Option(None, "--with-docs/--no-docs"),
112
+ cors_enabled: Optional[bool] = typer.Option(None, "--with-cors/--no-cors"),
113
+ tests_enabled: Optional[bool] = typer.Option(None, "--with-tests/--no-tests"),
114
+ docker_enabled: Optional[bool] = typer.Option(None, "--with-docker/--no-docker"),
115
+ ci_enabled: Optional[bool] = typer.Option(None, "--with-ci/--no-ci"),
116
+ auth_enabled: Optional[bool] = typer.Option(None, "--with-auth/--no-auth"),
117
+ database_enabled: Optional[bool] = typer.Option(None, "--with-database/--no-database"),
118
+ cache_enabled: Optional[bool] = typer.Option(None, "--with-cache/--no-cache"),
119
+ task_queue_enabled: Optional[bool] = typer.Option(None, "--with-task-queue/--no-task-queue"),
120
+ layout: Optional[str] = typer.Option(None, "--layout", help="app or src"),
121
+ ci_provider: Optional[str] = typer.Option(None, "--ci-provider", help="github or gitlab"),
122
+ force: bool = typer.Option(False, "--force", help="Overwrite in non-empty directories."),
123
+ dry_run: bool = typer.Option(False, "--dry-run", help="Only print the files that would be generated."),
124
+ bootstrap_env: bool = typer.Option(True, "--bootstrap-env/--no-bootstrap-env", help="Create .venv, uv.lock, and install dependencies with uv."),
125
+ ) -> None:
126
+ """Generate a FastAPI project."""
127
+ options = _build_options(
128
+ project_name=project_name,
129
+ package_name=package_name,
130
+ target_dir=target_dir,
131
+ app_name=app_name,
132
+ description=description,
133
+ python_version=python_version,
134
+ host=host,
135
+ port=port,
136
+ api_prefix=api_prefix,
137
+ docs_enabled=docs_enabled,
138
+ cors_enabled=cors_enabled,
139
+ tests_enabled=tests_enabled,
140
+ docker_enabled=docker_enabled,
141
+ ci_enabled=ci_enabled,
142
+ auth_enabled=auth_enabled,
143
+ database_enabled=database_enabled,
144
+ cache_enabled=cache_enabled,
145
+ task_queue_enabled=task_queue_enabled,
146
+ layout=layout,
147
+ ci_provider=ci_provider,
148
+ force=force,
149
+ dry_run=dry_run,
150
+ bootstrap_env=bootstrap_env,
151
+ )
152
+
153
+ try:
154
+ outputs = scaffold_project(options)
155
+ except FileExistsError as exc:
156
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
157
+ raise typer.Exit(code=1) from exc
158
+ except BootstrapError as exc:
159
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
160
+ raise typer.Exit(code=1) from exc
161
+
162
+ if options.dry_run:
163
+ typer.echo(f"Dry run for {options.project_name}:")
164
+ for path in build_plan(options):
165
+ typer.echo(f" would create {path}")
166
+ return
167
+
168
+ typer.secho(f"Generated FastAPI project at {options.target_dir}", fg=typer.colors.GREEN)
169
+ if options.bootstrap_env:
170
+ typer.echo("Environment bootstrapped with uv.")
171
+ typer.echo("Files created:")
172
+ for path in outputs:
173
+ typer.echo(f" {path.relative_to(options.target_dir)}")
inifastapi/options.py ADDED
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import re
6
+
7
+
8
+ DEFAULT_PYTHON_VERSION = "3.12.10"
9
+
10
+
11
+ def slugify_project_name(value: str) -> str:
12
+ cleaned = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip())
13
+ cleaned = cleaned.strip("-._")
14
+ return cleaned or "fastapi-app"
15
+
16
+
17
+ def normalize_package_name(value: str) -> str:
18
+ normalized = re.sub(r"[^a-zA-Z0-9_]+", "_", value.strip().lower())
19
+ normalized = normalized.strip("_")
20
+ if not normalized:
21
+ return "app"
22
+ if normalized[0].isdigit():
23
+ normalized = f"pkg_{normalized}"
24
+ return normalized
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class ProjectOptions:
29
+ project_name: str
30
+ package_name: str
31
+ target_dir: Path
32
+ app_name: str
33
+ description: str
34
+ python_version: str = DEFAULT_PYTHON_VERSION
35
+ host: str = "0.0.0.0"
36
+ port: int = 8000
37
+ api_prefix: str = "/api/v1"
38
+ docs_enabled: bool = True
39
+ cors_enabled: bool = True
40
+ tests_enabled: bool = True
41
+ docker_enabled: bool = False
42
+ ci_enabled: bool = False
43
+ auth_enabled: bool = False
44
+ database_enabled: bool = False
45
+ cache_enabled: bool = False
46
+ task_queue_enabled: bool = False
47
+ layout: str = "app"
48
+ ci_provider: str = "github"
49
+ force: bool = False
50
+ dry_run: bool = False
51
+ bootstrap_env: bool = True
52
+
53
+ def __post_init__(self) -> None:
54
+ self.project_name = slugify_project_name(self.project_name)
55
+ self.package_name = normalize_package_name(self.package_name)
56
+ self.target_dir = Path(self.target_dir).expanduser().resolve()
57
+ self.layout = self.layout if self.layout in {"app", "src"} else "app"
58
+ self.ci_provider = self.ci_provider if self.ci_provider in {"github", "gitlab"} else "github"
59
+ if self.port <= 0:
60
+ self.port = 8000
61
+ if not self.api_prefix.startswith("/"):
62
+ self.api_prefix = f"/{self.api_prefix}"
63
+ self.python_version = self.python_version.strip() or DEFAULT_PYTHON_VERSION
64
+ self.app_name = self.app_name.strip() or self.project_name
65
+ self.description = self.description.strip() or f"{self.app_name} service"
66
+
67
+ @property
68
+ def package_root(self) -> str:
69
+ return f"src/{self.package_name}" if self.layout == "src" else self.package_name
70
+
71
+ @property
72
+ def import_root(self) -> str:
73
+ return self.package_name
74
+
75
+ @property
76
+ def build_backend_module_root(self) -> str:
77
+ return "src" if self.layout == "src" else ""
78
+
79
+ @property
80
+ def vscode_extra_path(self) -> str:
81
+ return "${workspaceFolder}/src" if self.layout == "src" else "${workspaceFolder}"
82
+
83
+ @property
84
+ def pyright_extra_path(self) -> str:
85
+ return "src" if self.layout == "src" else "."
86
+
87
+ @property
88
+ def needs_rabbitmq(self) -> bool:
89
+ return self.task_queue_enabled
90
+
91
+ @property
92
+ def docs_url(self) -> str | None:
93
+ return "/docs" if self.docs_enabled else None
94
+
95
+ @property
96
+ def redoc_url(self) -> str | None:
97
+ return "/redoc" if self.docs_enabled else None
98
+
99
+ @property
100
+ def openapi_url(self) -> str | None:
101
+ return "/openapi.json" if self.docs_enabled else None
102
+
103
+ def template_context(self) -> dict[str, object]:
104
+ return {
105
+ "project_name": self.project_name,
106
+ "package_name": self.package_name,
107
+ "package_root": self.package_root,
108
+ "import_root": self.import_root,
109
+ "layout": self.layout,
110
+ "build_backend_module_root": self.build_backend_module_root,
111
+ "vscode_extra_path": self.vscode_extra_path,
112
+ "pyright_extra_path": self.pyright_extra_path,
113
+ "app_name": self.app_name,
114
+ "description": self.description,
115
+ "python_version": self.python_version,
116
+ "host": self.host,
117
+ "port": self.port,
118
+ "api_prefix": self.api_prefix,
119
+ "docs_enabled": self.docs_enabled,
120
+ "cors_enabled": self.cors_enabled,
121
+ "tests_enabled": self.tests_enabled,
122
+ "docker_enabled": self.docker_enabled,
123
+ "ci_enabled": self.ci_enabled,
124
+ "auth_enabled": self.auth_enabled,
125
+ "database_enabled": self.database_enabled,
126
+ "cache_enabled": self.cache_enabled,
127
+ "task_queue_enabled": self.task_queue_enabled,
128
+ "ci_provider": self.ci_provider,
129
+ "needs_rabbitmq": self.needs_rabbitmq,
130
+ "docs_url": self.docs_url,
131
+ "redoc_url": self.redoc_url,
132
+ "openapi_url": self.openapi_url,
133
+ "bootstrap_env": self.bootstrap_env,
134
+ }
inifastapi/scaffold.py ADDED
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from importlib.resources import files
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ from jinja2 import Environment, FileSystemLoader
9
+
10
+ from .bootstrap import bootstrap_project
11
+ from .options import ProjectOptions
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class TemplateEntry:
16
+ template_name: str
17
+ output_path: str
18
+ feature_flag: str | None = None
19
+
20
+
21
+ def _template_environment() -> Environment:
22
+ template_root = files("inifastapi").joinpath("templates")
23
+ return Environment(
24
+ loader=FileSystemLoader(str(template_root)),
25
+ autoescape=False,
26
+ keep_trailing_newline=True,
27
+ trim_blocks=True,
28
+ lstrip_blocks=True,
29
+ )
30
+
31
+
32
+ def _template_entries() -> list[TemplateEntry]:
33
+ base = [
34
+ TemplateEntry("project/pyproject.toml.j2", "pyproject.toml"),
35
+ TemplateEntry("project/README.md.j2", "README.md"),
36
+ TemplateEntry("project/.gitignore.j2", ".gitignore"),
37
+ TemplateEntry("project/.env.example.j2", ".env.example"),
38
+ TemplateEntry("project/.python-version.j2", ".python-version"),
39
+ TemplateEntry("project/vscode_settings.json.j2", ".vscode/settings.json"),
40
+ TemplateEntry("project/pyrightconfig.json.j2", "pyrightconfig.json"),
41
+ TemplateEntry("project/main.py.j2", "{package_root}/main.py"),
42
+ TemplateEntry("project/api_router.py.j2", "{package_root}/api/router.py"),
43
+ TemplateEntry("project/routes_health.py.j2", "{package_root}/api/routes/health.py"),
44
+ TemplateEntry("project/config.py.j2", "{package_root}/core/config.py"),
45
+ TemplateEntry("project/logging.py.j2", "{package_root}/core/logging.py"),
46
+ TemplateEntry("project/lifespan.py.j2", "{package_root}/core/lifespan.py"),
47
+ TemplateEntry("project/middleware.py.j2", "{package_root}/core/middleware.py"),
48
+ TemplateEntry("project/dependencies_init.py.j2", "{package_root}/dependencies/__init__.py"),
49
+ TemplateEntry("project/models_init.py.j2", "{package_root}/models/__init__.py"),
50
+ TemplateEntry("project/schemas_init.py.j2", "{package_root}/schemas/__init__.py"),
51
+ TemplateEntry("project/services_init.py.j2", "{package_root}/services/__init__.py"),
52
+ TemplateEntry("project/package_init.py.j2", "{package_root}/__init__.py"),
53
+ TemplateEntry("project/api_init.py.j2", "{package_root}/api/__init__.py"),
54
+ TemplateEntry("project/api_routes_init.py.j2", "{package_root}/api/routes/__init__.py"),
55
+ TemplateEntry("project/core_init.py.j2", "{package_root}/core/__init__.py"),
56
+ TemplateEntry("project/tests_health.py.j2", "tests/test_health.py", "tests_enabled"),
57
+ ]
58
+ database = [
59
+ TemplateEntry("database/db_init.py.j2", "{package_root}/db/__init__.py", "database_enabled"),
60
+ TemplateEntry("database/base.py.j2", "{package_root}/db/base.py", "database_enabled"),
61
+ TemplateEntry("database/session.py.j2", "{package_root}/db/session.py", "database_enabled"),
62
+ TemplateEntry("database/alembic.ini.j2", "alembic.ini", "database_enabled"),
63
+ TemplateEntry("database/alembic_env.py.j2", "alembic/env.py", "database_enabled"),
64
+ TemplateEntry("database/alembic_script.py.mako.j2", "alembic/script.py.mako", "database_enabled"),
65
+ TemplateEntry("database/alembic_readme.py.j2", "alembic/versions/.gitkeep", "database_enabled"),
66
+ ]
67
+ auth = [
68
+ TemplateEntry("auth/routes_auth.py.j2", "{package_root}/api/routes/auth.py", "auth_enabled"),
69
+ TemplateEntry("auth/dependencies_auth.py.j2", "{package_root}/dependencies/auth.py", "auth_enabled"),
70
+ TemplateEntry("auth/security.py.j2", "{package_root}/core/security.py", "auth_enabled"),
71
+ TemplateEntry("auth/schemas_auth.py.j2", "{package_root}/schemas/auth.py", "auth_enabled"),
72
+ ]
73
+ cache = [
74
+ TemplateEntry("cache/cache.py.j2", "{package_root}/core/cache.py", "cache_enabled"),
75
+ ]
76
+ task_queue = [
77
+ TemplateEntry("task_queue/routes_tasks.py.j2", "{package_root}/api/routes/tasks.py", "task_queue_enabled"),
78
+ TemplateEntry("task_queue/tasks_init.py.j2", "{package_root}/tasks/__init__.py", "task_queue_enabled"),
79
+ TemplateEntry("task_queue/celery_app.py.j2", "{package_root}/tasks/celery_app.py", "task_queue_enabled"),
80
+ TemplateEntry("task_queue/sample_task.py.j2", "{package_root}/tasks/sample.py", "task_queue_enabled"),
81
+ ]
82
+ docker = [
83
+ TemplateEntry("docker/Dockerfile.j2", "Dockerfile", "docker_enabled"),
84
+ TemplateEntry("docker/.dockerignore.j2", ".dockerignore", "docker_enabled"),
85
+ TemplateEntry("docker/docker-compose.yml.j2", "docker-compose.yml", "docker_enabled"),
86
+ ]
87
+ ci = [
88
+ TemplateEntry("ci/github.yml.j2", ".github/workflows/ci.yml", "ci_enabled"),
89
+ TemplateEntry("ci/gitlab-ci.yml.j2", ".gitlab-ci.yml", "ci_enabled"),
90
+ ]
91
+ return base + database + auth + cache + task_queue + docker + ci
92
+
93
+
94
+ def build_plan(options: ProjectOptions) -> list[Path]:
95
+ outputs: list[Path] = []
96
+ for entry in _iter_enabled_entries(options):
97
+ output_path = entry.output_path.format(package_root=options.package_root)
98
+ outputs.append(options.target_dir / output_path)
99
+ return outputs
100
+
101
+
102
+ def _iter_enabled_entries(options: ProjectOptions) -> Iterable[TemplateEntry]:
103
+ for entry in _template_entries():
104
+ if entry.feature_flag is None:
105
+ yield entry
106
+ continue
107
+ if entry.template_name.startswith("ci/"):
108
+ if options.ci_enabled and (
109
+ (options.ci_provider == "github" and entry.template_name.endswith("github.yml.j2"))
110
+ or (options.ci_provider == "gitlab" and entry.template_name.endswith("gitlab-ci.yml.j2"))
111
+ ):
112
+ yield entry
113
+ continue
114
+ if getattr(options, entry.feature_flag):
115
+ yield entry
116
+
117
+
118
+ def scaffold_project(options: ProjectOptions) -> list[Path]:
119
+ plan = build_plan(options)
120
+ _validate_target_dir(options)
121
+ if options.dry_run:
122
+ return plan
123
+
124
+ env = _template_environment()
125
+ context = options.template_context()
126
+
127
+ options.target_dir.mkdir(parents=True, exist_ok=True)
128
+ for path in plan:
129
+ path.parent.mkdir(parents=True, exist_ok=True)
130
+
131
+ for entry in _iter_enabled_entries(options):
132
+ relative_output = entry.output_path.format(package_root=options.package_root)
133
+ output_path = options.target_dir / relative_output
134
+ rendered = env.get_template(entry.template_name).render(**context)
135
+ output_path.write_text(rendered, encoding="utf-8")
136
+
137
+ if options.bootstrap_env:
138
+ bootstrap_project(options)
139
+
140
+ return plan
141
+
142
+
143
+ def _validate_target_dir(options: ProjectOptions) -> None:
144
+ if not options.target_dir.exists():
145
+ return
146
+ existing_files = [item for item in options.target_dir.iterdir()]
147
+ if not existing_files:
148
+ return
149
+ if options.force:
150
+ return
151
+ raise FileExistsError(
152
+ f"Target directory '{options.target_dir}' is not empty. Use --force to overwrite generated files."
153
+ )
@@ -0,0 +1,19 @@
1
+ from fastapi import Depends, HTTPException, status
2
+ from fastapi.security import OAuth2PasswordBearer
3
+
4
+ from {{ import_root }}.core.security import decode_access_token
5
+ from {{ import_root }}.schemas.auth import User
6
+
7
+
8
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
9
+
10
+
11
+ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
12
+ subject = decode_access_token(token)
13
+ if subject is None:
14
+ raise HTTPException(
15
+ status_code=status.HTTP_401_UNAUTHORIZED,
16
+ detail="Invalid authentication credentials",
17
+ headers={"WWW-Authenticate": "Bearer"},
18
+ )
19
+ return User(username=subject)
@@ -0,0 +1,26 @@
1
+ from datetime import timedelta
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from fastapi.security import OAuth2PasswordRequestForm
5
+
6
+ from {{ import_root }}.core.config import settings
7
+ from {{ import_root }}.core.security import create_access_token
8
+ from {{ import_root }}.dependencies.auth import get_current_user
9
+ from {{ import_root }}.schemas.auth import Token, User
10
+
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.post("/login", response_model=Token)
16
+ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> Token:
17
+ access_token = create_access_token(
18
+ subject=form_data.username,
19
+ expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
20
+ )
21
+ return Token(access_token=access_token, token_type="bearer")
22
+
23
+
24
+ @router.get("/me", response_model=User)
25
+ async def read_current_user(current_user: User = Depends(get_current_user)) -> User:
26
+ return current_user
@@ -0,0 +1,10 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Token(BaseModel):
5
+ access_token: str
6
+ token_type: str
7
+
8
+
9
+ class User(BaseModel):
10
+ username: str
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from jose import JWTError, jwt
6
+
7
+ from {{ import_root }}.core.config import settings
8
+
9
+
10
+ def create_access_token(subject: str, expires_delta: timedelta) -> str:
11
+ expire = datetime.now(timezone.utc) + expires_delta
12
+ payload = {"sub": subject, "exp": expire}
13
+ return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
14
+
15
+
16
+ def decode_access_token(token: str) -> str | None:
17
+ try:
18
+ payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
19
+ except JWTError:
20
+ return None
21
+ return payload.get("sub")
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from redis.asyncio import Redis
4
+
5
+ from {{ import_root }}.core.config import settings
6
+
7
+
8
+ def get_cache_client() -> Redis:
9
+ return Redis.from_url(settings.redis_url, encoding="utf-8", decode_responses=True)
@@ -0,0 +1,20 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "{{ python_version }}"
15
+ - name: Install uv
16
+ run: pip install uv
17
+ - name: Install dependencies
18
+ run: uv sync
19
+ - name: Run tests
20
+ run: uv run pytest