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.
- inifastapi/__init__.py +7 -0
- inifastapi/__main__.py +5 -0
- inifastapi/bootstrap.py +94 -0
- inifastapi/cli.py +173 -0
- inifastapi/options.py +134 -0
- inifastapi/scaffold.py +153 -0
- inifastapi/templates/auth/dependencies_auth.py.j2 +19 -0
- inifastapi/templates/auth/routes_auth.py.j2 +26 -0
- inifastapi/templates/auth/schemas_auth.py.j2 +10 -0
- inifastapi/templates/auth/security.py.j2 +21 -0
- inifastapi/templates/cache/cache.py.j2 +9 -0
- inifastapi/templates/ci/github.yml.j2 +20 -0
- inifastapi/templates/ci/gitlab-ci.yml.j2 +11 -0
- inifastapi/templates/database/alembic.ini.j2 +35 -0
- inifastapi/templates/database/alembic_env.py.j2 +58 -0
- inifastapi/templates/database/alembic_readme.py.j2 +1 -0
- inifastapi/templates/database/alembic_script.py.mako.j2 +14 -0
- inifastapi/templates/database/base.py.j2 +7 -0
- inifastapi/templates/database/db_init.py.j2 +1 -0
- inifastapi/templates/database/session.py.j2 +14 -0
- inifastapi/templates/docker/.dockerignore.j2 +6 -0
- inifastapi/templates/docker/Dockerfile.j2 +15 -0
- inifastapi/templates/docker/docker-compose.yml.j2 +47 -0
- inifastapi/templates/project/.env.example.j2 +31 -0
- inifastapi/templates/project/.gitignore.j2 +10 -0
- inifastapi/templates/project/.python-version.j2 +1 -0
- inifastapi/templates/project/README.md.j2 +47 -0
- inifastapi/templates/project/api_init.py.j2 +1 -0
- inifastapi/templates/project/api_router.py.j2 +13 -0
- inifastapi/templates/project/api_routes_init.py.j2 +1 -0
- inifastapi/templates/project/config.py.j2 +45 -0
- inifastapi/templates/project/core_init.py.j2 +1 -0
- inifastapi/templates/project/dependencies_init.py.j2 +1 -0
- inifastapi/templates/project/lifespan.py.j2 +25 -0
- inifastapi/templates/project/logging.py.j2 +15 -0
- inifastapi/templates/project/main.py.j2 +32 -0
- inifastapi/templates/project/middleware.py.j2 +51 -0
- inifastapi/templates/project/models_init.py.j2 +1 -0
- inifastapi/templates/project/package_init.py.j2 +1 -0
- inifastapi/templates/project/pyproject.toml.j2 +18 -0
- inifastapi/templates/project/pyrightconfig.json.j2 +15 -0
- inifastapi/templates/project/routes_health.py.j2 +9 -0
- inifastapi/templates/project/schemas_init.py.j2 +1 -0
- inifastapi/templates/project/services_init.py.j2 +1 -0
- inifastapi/templates/project/tests_health.py.j2 +24 -0
- inifastapi/templates/project/vscode_settings.json.j2 +13 -0
- inifastapi/templates/task_queue/celery_app.py.j2 +7 -0
- inifastapi/templates/task_queue/routes_tasks.py.j2 +15 -0
- inifastapi/templates/task_queue/sample_task.py.j2 +6 -0
- inifastapi/templates/task_queue/tasks_init.py.j2 +1 -0
- inifastapi-0.1.0.dist-info/METADATA +85 -0
- inifastapi-0.1.0.dist-info/RECORD +54 -0
- inifastapi-0.1.0.dist-info/WHEEL +4 -0
- 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
inifastapi/bootstrap.py
ADDED
|
@@ -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,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,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
|