paststack 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.
- paststack/__init__.py +1 -0
- paststack/banner.py +19 -0
- paststack/cli.py +353 -0
- paststack/combinations.py +68 -0
- paststack/models.py +30 -0
- paststack/prompts.py +92 -0
- paststack/templates/base/.env.example +2 -0
- paststack/templates/base/.gitignore +15 -0
- paststack/templates/base/README.md +39 -0
- paststack/templates/base/pyproject.toml +52 -0
- paststack/templates/base/src/app/__init__.py +5 -0
- paststack/templates/base/src/app/api/__init__.py +1 -0
- paststack/templates/base/src/app/api/deps.py +1 -0
- paststack/templates/base/src/app/api/router.py +6 -0
- paststack/templates/base/src/app/api/routes/__init__.py +1 -0
- paststack/templates/base/src/app/api/routes/health.py +17 -0
- paststack/templates/base/src/app/core/__init__.py +1 -0
- paststack/templates/base/src/app/core/config.py +22 -0
- paststack/templates/base/src/app/database.py +13 -0
- paststack/templates/base/src/app/main.py +42 -0
- paststack/templates/base/src/app/models/__init__.py +1 -0
- paststack/templates/base/src/app/schemas/__init__.py +1 -0
- paststack/templates/database/postgres/docker-compose.yml +14 -0
- paststack/templates/database/postgres/none/database.py +27 -0
- paststack/templates/database/postgres/sqlmodel/api/deps.py +10 -0
- paststack/templates/database/postgres/sqlmodel/database.py +38 -0
- paststack/templates/database/postgres/sqlmodel/models/__init__.py +5 -0
- paststack/templates/database/sqlite/none/database.py +27 -0
- paststack/templates/database/sqlite/sqlmodel/api/deps.py +10 -0
- paststack/templates/database/sqlite/sqlmodel/database.py +40 -0
- paststack/templates/database/sqlite/sqlmodel/models/__init__.py +5 -0
- paststack/templates/rate_limiting/src/app/core/rate_limit.py +14 -0
- paststack-0.1.0.dist-info/METADATA +129 -0
- paststack-0.1.0.dist-info/RECORD +38 -0
- paststack-0.1.0.dist-info/WHEEL +5 -0
- paststack-0.1.0.dist-info/entry_points.txt +2 -0
- paststack-0.1.0.dist-info/licenses/LICENSE +21 -0
- paststack-0.1.0.dist-info/top_level.txt +1 -0
paststack/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
paststack/banner.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def display_banner() -> None:
|
|
5
|
+
click.echo(
|
|
6
|
+
r"""
|
|
7
|
+
/$$$$$$$ /$$ /$$$$$$ /$$ /$$
|
|
8
|
+
| $$__ $$ | $$ /$$__ $$ | $$ | $$
|
|
9
|
+
| $$ \ $$ /$$$$$$ /$$$$$$$ /$$$$$$ | $$ \__//$$$$$$ /$$$$$$ /$$$$$$$| $$ /$$
|
|
10
|
+
| $$$$$$$/|____ $$ /$$_____/|_ $$_/ | $$$$$$|_ $$_/ |____ $$ /$$_____/| $$ /$$/
|
|
11
|
+
| $$____/ /$$$$$$$| $$$$$$ | $$ \____ $$ | $$ /$$$$$$$| $$ | $$$$$$/
|
|
12
|
+
| $$ /$$__ $$ \____ $$ | $$ /$$ /$$ \ $$ | $$ /$$ /$$__ $$| $$ | $$_ $$
|
|
13
|
+
| $$ | $$$$$$$ /$$$$$$$/ | $$$$/| $$$$$$/ | $$$$/| $$$$$$$| $$$$$$$| $$ \ $$
|
|
14
|
+
|__/ \_______/|_______/ \___/ \______/ \___/ \_______/ \_______/|__/ \__/
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
)
|
paststack/cli.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from shutil import copyfile, which
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import questionary
|
|
10
|
+
from rich.progress import (
|
|
11
|
+
BarColumn,
|
|
12
|
+
Progress,
|
|
13
|
+
SpinnerColumn,
|
|
14
|
+
TaskProgressColumn,
|
|
15
|
+
TextColumn,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from paststack.banner import display_banner
|
|
19
|
+
from paststack.models import Database, Orm, Project
|
|
20
|
+
from paststack.prompts import ask_questions, show_summary
|
|
21
|
+
|
|
22
|
+
TEMPLATES_ROOT = Path(__file__).resolve().parent / "templates"
|
|
23
|
+
BASE = TEMPLATES_ROOT / "base"
|
|
24
|
+
DATABASE = TEMPLATES_ROOT / "database"
|
|
25
|
+
RATE_LIMITING = TEMPLATES_ROOT / "rate_limiting"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _venv_python(venv_path: Path) -> Path:
|
|
29
|
+
if sys.platform == "win32":
|
|
30
|
+
return venv_path / "Scripts" / "python.exe"
|
|
31
|
+
return venv_path / "bin" / "python"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _database_url(project: Project) -> str:
|
|
35
|
+
if project.database == Database.NONE:
|
|
36
|
+
return "sqlite+aiosqlite:///./data/app.db"
|
|
37
|
+
if project.database == Database.SQLITE:
|
|
38
|
+
return "sqlite+aiosqlite:///./data/app.db"
|
|
39
|
+
if project.orm == Orm.SQLMODEL:
|
|
40
|
+
return "postgresql+asyncpg://app:app@127.0.0.1:5432/app"
|
|
41
|
+
return "postgresql://app:app@127.0.0.1:5432/app"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _env_database_block(project: Project) -> str:
|
|
45
|
+
if project.database == Database.NONE:
|
|
46
|
+
return "# Pas de base de données (DATABASE_URL ignoré)"
|
|
47
|
+
return f"DATABASE_URL={_database_url(project)}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_extra(project: Project) -> dict[str, str]:
|
|
51
|
+
extra: dict[str, str] = {
|
|
52
|
+
"{{ database_url }}": _database_url(project),
|
|
53
|
+
"{{ env_database_block }}": _env_database_block(project),
|
|
54
|
+
}
|
|
55
|
+
if project.rate_limiting:
|
|
56
|
+
extra["{{ rate_limit_imports }}"] = (
|
|
57
|
+
"from app.core.rate_limit import setup_rate_limiting\n"
|
|
58
|
+
)
|
|
59
|
+
extra["{{ rate_limit_setup }}"] = "setup_rate_limiting(app)\n"
|
|
60
|
+
else:
|
|
61
|
+
extra["{{ rate_limit_imports }}"] = ""
|
|
62
|
+
extra["{{ rate_limit_setup }}"] = ""
|
|
63
|
+
return extra
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _subprocess_env_without_venv() -> dict[str, str]:
|
|
67
|
+
env = os.environ.copy()
|
|
68
|
+
env.pop("VIRTUAL_ENV", None)
|
|
69
|
+
return env
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _docker_compose_up_postgres(project_root: Path) -> bool:
|
|
73
|
+
"""Démarre le conteneur Postgres du projet. Retourne False si Docker indisponible ou échec."""
|
|
74
|
+
if not (project_root / "docker-compose.yml").is_file():
|
|
75
|
+
click.echo("Aucun docker-compose.yml — impossible de lancer PostgreSQL ici.", err=True)
|
|
76
|
+
return False
|
|
77
|
+
if which("docker") is None:
|
|
78
|
+
click.echo(
|
|
79
|
+
click.style(
|
|
80
|
+
"La commande `docker` est introuvable.\n"
|
|
81
|
+
"→ Installe Docker Desktop : https://www.docker.com/products/docker-desktop/\n"
|
|
82
|
+
"→ Puis dans le projet : docker compose up -d",
|
|
83
|
+
fg="red",
|
|
84
|
+
),
|
|
85
|
+
err=True,
|
|
86
|
+
)
|
|
87
|
+
return False
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
["docker", "compose", "up", "-d"],
|
|
90
|
+
cwd=project_root,
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
env=_subprocess_env_without_venv(),
|
|
94
|
+
)
|
|
95
|
+
if result.returncode != 0:
|
|
96
|
+
click.echo(click.style("docker compose up -d a échoué.", fg="red"), err=True)
|
|
97
|
+
if result.stderr.strip():
|
|
98
|
+
click.echo(result.stderr, err=True)
|
|
99
|
+
if result.stdout.strip():
|
|
100
|
+
click.echo(result.stdout, err=True)
|
|
101
|
+
click.echo(
|
|
102
|
+
click.style(
|
|
103
|
+
"Vérifie que Docker Desktop est démarré (icône baleine active).\n"
|
|
104
|
+
"Ensuite, à la main : docker compose up -d",
|
|
105
|
+
fg="yellow",
|
|
106
|
+
),
|
|
107
|
+
err=True,
|
|
108
|
+
)
|
|
109
|
+
return False
|
|
110
|
+
click.echo(click.style("PostgreSQL : conteneur démarré (docker compose up -d).", fg="green"))
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _uv_extras(project: Project) -> list[str]:
|
|
115
|
+
extras: list[str] = []
|
|
116
|
+
if project.database == Database.SQLITE:
|
|
117
|
+
extras.append(
|
|
118
|
+
"sqlite-sqlmodel" if project.orm == Orm.SQLMODEL else "sqlite-none"
|
|
119
|
+
)
|
|
120
|
+
elif project.database == Database.POSTGRES:
|
|
121
|
+
extras.append(
|
|
122
|
+
"postgres-sqlmodel" if project.orm == Orm.SQLMODEL else "postgres-none"
|
|
123
|
+
)
|
|
124
|
+
if project.rate_limiting:
|
|
125
|
+
extras.append("rate-limit")
|
|
126
|
+
return extras
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@click.command()
|
|
130
|
+
def main() -> None:
|
|
131
|
+
try:
|
|
132
|
+
display_banner()
|
|
133
|
+
while True:
|
|
134
|
+
project = ask_questions()
|
|
135
|
+
show_summary(project)
|
|
136
|
+
if questionary.confirm("Proceed with project setup ?").unsafe_ask():
|
|
137
|
+
break
|
|
138
|
+
setup_project(project)
|
|
139
|
+
except KeyboardInterrupt:
|
|
140
|
+
click.echo("\nAborted.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _create_directories(base: Path, paths: list[tuple[str, ...]]) -> None:
|
|
144
|
+
for parts in paths:
|
|
145
|
+
(base / Path(*parts)).mkdir(parents=True, exist_ok=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _copy_and_render(
|
|
149
|
+
template: Path,
|
|
150
|
+
destination: Path,
|
|
151
|
+
project: Project,
|
|
152
|
+
extra: dict[str, str] | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
copyfile(template, destination)
|
|
156
|
+
content = destination.read_text(encoding="utf-8")
|
|
157
|
+
content = content.replace("{{ project_name }}", project.project_name)
|
|
158
|
+
origins = project.allowed_origins or []
|
|
159
|
+
content = content.replace("{{ allowed_origins }}", repr(origins))
|
|
160
|
+
content = content.replace("{{project.allowed_origins}}", repr(origins))
|
|
161
|
+
content = content.replace("{{ database }}", project.database.value)
|
|
162
|
+
content = content.replace("{{ orm }}", project.orm.value)
|
|
163
|
+
for key, value in (extra or {}).items():
|
|
164
|
+
content = content.replace(key, value)
|
|
165
|
+
destination.write_text(content, encoding="utf-8")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _copy_template_tree(
|
|
169
|
+
template_dir: Path,
|
|
170
|
+
dest_root: Path,
|
|
171
|
+
project: Project,
|
|
172
|
+
extra: dict[str, str] | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
if not template_dir.is_dir():
|
|
175
|
+
return
|
|
176
|
+
for path in sorted(template_dir.rglob("*")):
|
|
177
|
+
if path.is_file():
|
|
178
|
+
rel = path.relative_to(template_dir)
|
|
179
|
+
_copy_and_render(path, dest_root / rel, project, extra)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def setup_project(project: Project) -> None:
|
|
183
|
+
main_directory = Path(project.project_name).resolve()
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
main_directory.mkdir()
|
|
187
|
+
except FileExistsError:
|
|
188
|
+
click.echo(f"Directory '{main_directory}' already exists.")
|
|
189
|
+
return
|
|
190
|
+
except PermissionError:
|
|
191
|
+
click.echo(f"Permission denied: unable to create '{main_directory}'.")
|
|
192
|
+
return
|
|
193
|
+
except OSError as e:
|
|
194
|
+
click.echo(f"An error occurred: {e}")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
extra = _build_extra(project)
|
|
198
|
+
_copy_template_tree(BASE, main_directory, project, extra)
|
|
199
|
+
|
|
200
|
+
db_dest = main_directory / "src" / "app" / "database.py"
|
|
201
|
+
|
|
202
|
+
match (project.database, project.orm):
|
|
203
|
+
case (Database.NONE, _):
|
|
204
|
+
pass
|
|
205
|
+
case (Database.SQLITE, Orm.NONE):
|
|
206
|
+
_copy_and_render(
|
|
207
|
+
DATABASE / "sqlite" / "none" / "database.py", db_dest, project, extra
|
|
208
|
+
)
|
|
209
|
+
case (Database.SQLITE, Orm.SQLMODEL):
|
|
210
|
+
_copy_template_tree(
|
|
211
|
+
DATABASE / "sqlite" / "sqlmodel",
|
|
212
|
+
main_directory / "src" / "app",
|
|
213
|
+
project,
|
|
214
|
+
extra,
|
|
215
|
+
)
|
|
216
|
+
case (Database.POSTGRES, Orm.NONE):
|
|
217
|
+
_copy_and_render(
|
|
218
|
+
DATABASE / "postgres" / "none" / "database.py", db_dest, project, extra
|
|
219
|
+
)
|
|
220
|
+
_copy_and_render(
|
|
221
|
+
DATABASE / "postgres" / "docker-compose.yml",
|
|
222
|
+
main_directory / "docker-compose.yml",
|
|
223
|
+
project,
|
|
224
|
+
extra,
|
|
225
|
+
)
|
|
226
|
+
case (Database.POSTGRES, Orm.SQLMODEL):
|
|
227
|
+
_copy_template_tree(
|
|
228
|
+
DATABASE / "postgres" / "sqlmodel",
|
|
229
|
+
main_directory / "src" / "app",
|
|
230
|
+
project,
|
|
231
|
+
extra,
|
|
232
|
+
)
|
|
233
|
+
_copy_and_render(
|
|
234
|
+
DATABASE / "postgres" / "docker-compose.yml",
|
|
235
|
+
main_directory / "docker-compose.yml",
|
|
236
|
+
project,
|
|
237
|
+
extra,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if project.rate_limiting:
|
|
241
|
+
_copy_template_tree(RATE_LIMITING, main_directory, project, extra)
|
|
242
|
+
|
|
243
|
+
venv_path = (main_directory / project.project_name).resolve()
|
|
244
|
+
py_exe = _venv_python(venv_path).resolve()
|
|
245
|
+
|
|
246
|
+
with Progress(
|
|
247
|
+
SpinnerColumn(),
|
|
248
|
+
TextColumn("[bold blue]{task.description}"),
|
|
249
|
+
BarColumn(bar_width=40),
|
|
250
|
+
TaskProgressColumn(),
|
|
251
|
+
TextColumn(" "),
|
|
252
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
253
|
+
) as progress:
|
|
254
|
+
t_venv = progress.add_task("Création du venv…", total=1)
|
|
255
|
+
subprocess.run(
|
|
256
|
+
[sys.executable, "-m", "venv", str(venv_path)],
|
|
257
|
+
check=True,
|
|
258
|
+
env=_subprocess_env_without_venv(),
|
|
259
|
+
)
|
|
260
|
+
progress.update(t_venv, completed=1)
|
|
261
|
+
|
|
262
|
+
if project.run_install:
|
|
263
|
+
uv_extras = _uv_extras(project)
|
|
264
|
+
cmd = ["uv", "sync", "-p", str(py_exe)]
|
|
265
|
+
for e in uv_extras:
|
|
266
|
+
cmd.append(f"--extra={e}")
|
|
267
|
+
t_sync = progress.add_task("uv sync (dépendances)…", total=1)
|
|
268
|
+
subprocess.run(
|
|
269
|
+
cmd,
|
|
270
|
+
cwd=main_directory,
|
|
271
|
+
check=True,
|
|
272
|
+
env=_subprocess_env_without_venv(),
|
|
273
|
+
)
|
|
274
|
+
progress.update(t_sync, completed=1)
|
|
275
|
+
|
|
276
|
+
if project.git_z:
|
|
277
|
+
subprocess.run(["git", "init"], cwd=main_directory, check=True)
|
|
278
|
+
# --default : config git-z sans wizard interactif (sinon le CLI semble « figé »)
|
|
279
|
+
result = subprocess.run(
|
|
280
|
+
["git", "z", "init", "--default"],
|
|
281
|
+
cwd=main_directory,
|
|
282
|
+
capture_output=True,
|
|
283
|
+
text=True,
|
|
284
|
+
)
|
|
285
|
+
if result.returncode != 0:
|
|
286
|
+
click.echo(
|
|
287
|
+
"git z init a échoué (git-z installé ?). "
|
|
288
|
+
"Voir https://github.com/ejpcmac/git-z",
|
|
289
|
+
err=True,
|
|
290
|
+
)
|
|
291
|
+
if result.stderr:
|
|
292
|
+
click.echo(result.stderr, err=True)
|
|
293
|
+
elif project.git:
|
|
294
|
+
subprocess.run(["git", "init"], cwd=main_directory, check=True)
|
|
295
|
+
|
|
296
|
+
env_example = main_directory / ".env.example"
|
|
297
|
+
env_dest = main_directory / ".env"
|
|
298
|
+
if env_example.is_file() and not env_dest.is_file():
|
|
299
|
+
copyfile(env_example, env_dest)
|
|
300
|
+
click.echo("Fichier .env créé à partir de .env.example.")
|
|
301
|
+
|
|
302
|
+
start_uvicorn = project.run_install and questionary.confirm(
|
|
303
|
+
"Démarrer l’API maintenant (uvicorn --reload) ?",
|
|
304
|
+
default=True,
|
|
305
|
+
).unsafe_ask()
|
|
306
|
+
|
|
307
|
+
if start_uvicorn and project.database == Database.POSTGRES:
|
|
308
|
+
if questionary.confirm(
|
|
309
|
+
"Lancer PostgreSQL avec Docker (`docker compose up -d`) ?",
|
|
310
|
+
default=True,
|
|
311
|
+
).unsafe_ask():
|
|
312
|
+
if not _docker_compose_up_postgres(main_directory):
|
|
313
|
+
start_uvicorn = questionary.confirm(
|
|
314
|
+
"Lancer uvicorn quand même ? (l’API échouera si Postgres n’est pas joignable.)",
|
|
315
|
+
default=False,
|
|
316
|
+
).unsafe_ask()
|
|
317
|
+
else:
|
|
318
|
+
time.sleep(2)
|
|
319
|
+
else:
|
|
320
|
+
click.echo(
|
|
321
|
+
click.style(
|
|
322
|
+
"Sans conteneur Docker, assure-toi que Postgres tourne déjà "
|
|
323
|
+
"(même URL que dans `.env`), sinon le démarrage de l’API échouera.",
|
|
324
|
+
fg="yellow",
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
start_uvicorn = questionary.confirm(
|
|
328
|
+
"Lancer uvicorn quand même ?",
|
|
329
|
+
default=False,
|
|
330
|
+
).unsafe_ask()
|
|
331
|
+
|
|
332
|
+
if start_uvicorn:
|
|
333
|
+
click.echo(
|
|
334
|
+
click.style(
|
|
335
|
+
"\n→ http://127.0.0.1:8000/docs (Ctrl+C pour arrêter)\n",
|
|
336
|
+
fg="green",
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
subprocess.run(
|
|
340
|
+
[
|
|
341
|
+
"uv",
|
|
342
|
+
"run",
|
|
343
|
+
"--python",
|
|
344
|
+
str(py_exe),
|
|
345
|
+
"uvicorn",
|
|
346
|
+
"app.main:app",
|
|
347
|
+
"--reload",
|
|
348
|
+
"--app-dir",
|
|
349
|
+
"src",
|
|
350
|
+
],
|
|
351
|
+
cwd=main_directory,
|
|
352
|
+
env=_subprocess_env_without_venv(),
|
|
353
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Combinaisons valides du générateur — réutilisables par les tests ou d’autres outils."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from paststack.models import Database, Orm, Project
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def iter_database_orm_rate_limit_triples() -> Iterator[tuple[Database, Orm, bool]]:
|
|
11
|
+
"""Toutes les combinaisons métier valides (SGBD × ORM × rate limiting)."""
|
|
12
|
+
for rate_limiting in (False, True):
|
|
13
|
+
yield (Database.NONE, Orm.NONE, rate_limiting)
|
|
14
|
+
|
|
15
|
+
for database in (Database.SQLITE, Database.POSTGRES):
|
|
16
|
+
for orm in (Orm.NONE, Orm.SQLMODEL):
|
|
17
|
+
for rate_limiting in (False, True):
|
|
18
|
+
yield (database, orm, rate_limiting)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def make_project(
|
|
22
|
+
database: Database,
|
|
23
|
+
orm: Orm,
|
|
24
|
+
*,
|
|
25
|
+
rate_limiting: bool = False,
|
|
26
|
+
project_name: str = "generated_app",
|
|
27
|
+
run_install: bool = False,
|
|
28
|
+
git: bool = False,
|
|
29
|
+
git_z: bool = False,
|
|
30
|
+
) -> Project:
|
|
31
|
+
"""Construit un `Project` cohérent (sans base → ORM forcé à none)."""
|
|
32
|
+
if database == Database.NONE:
|
|
33
|
+
orm = Orm.NONE
|
|
34
|
+
return Project(
|
|
35
|
+
project_name=project_name,
|
|
36
|
+
package_manager="uv",
|
|
37
|
+
use_typing=True,
|
|
38
|
+
use_ruff=True,
|
|
39
|
+
enable_cors=False,
|
|
40
|
+
allowed_origins=None,
|
|
41
|
+
database=database,
|
|
42
|
+
orm=orm,
|
|
43
|
+
rate_limiting=rate_limiting,
|
|
44
|
+
config=True,
|
|
45
|
+
git=git,
|
|
46
|
+
git_z=git_z,
|
|
47
|
+
run_install=run_install,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def iter_all_projects(
|
|
52
|
+
*,
|
|
53
|
+
project_name: str = "generated_app",
|
|
54
|
+
run_install: bool = False,
|
|
55
|
+
) -> Iterator[Project]:
|
|
56
|
+
"""Itère un `Project` par combinaison valide."""
|
|
57
|
+
for database, orm, rate_limiting in iter_database_orm_rate_limit_triples():
|
|
58
|
+
yield make_project(
|
|
59
|
+
database,
|
|
60
|
+
orm,
|
|
61
|
+
rate_limiting=rate_limiting,
|
|
62
|
+
project_name=project_name,
|
|
63
|
+
run_install=run_install,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def combination_count() -> int:
|
|
68
|
+
return sum(1 for _ in iter_database_orm_rate_limit_triples())
|
paststack/models.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Database(str, Enum):
|
|
7
|
+
NONE = "none"
|
|
8
|
+
SQLITE = "sqlite"
|
|
9
|
+
POSTGRES = "postgres"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Orm(str, Enum):
|
|
13
|
+
NONE = "none"
|
|
14
|
+
SQLMODEL = "sqlmodel"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Project(BaseModel):
|
|
18
|
+
project_name: str
|
|
19
|
+
package_manager: str
|
|
20
|
+
use_typing: bool
|
|
21
|
+
use_ruff: bool
|
|
22
|
+
enable_cors: bool
|
|
23
|
+
allowed_origins: list[str] | None = None
|
|
24
|
+
database: Database
|
|
25
|
+
orm: Orm
|
|
26
|
+
rate_limiting: bool
|
|
27
|
+
config: bool
|
|
28
|
+
git: bool
|
|
29
|
+
git_z: bool
|
|
30
|
+
run_install: bool
|
paststack/prompts.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import questionary
|
|
3
|
+
|
|
4
|
+
from paststack.models import Database, Orm, Project
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def ask_questions() -> Project:
|
|
8
|
+
project_name = questionary.text(
|
|
9
|
+
"Project name",
|
|
10
|
+
default="my_fastapi_app",
|
|
11
|
+
).unsafe_ask()
|
|
12
|
+
|
|
13
|
+
raw_allowed_origins: str = questionary.text(
|
|
14
|
+
"Allowed origins (CORS) [comma-separated, empty = disabled]",
|
|
15
|
+
default="",
|
|
16
|
+
).unsafe_ask()
|
|
17
|
+
|
|
18
|
+
allowed_origins: list[str] = [
|
|
19
|
+
origin.strip() for origin in raw_allowed_origins.split(",") if origin.strip()
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
raw_database: str = questionary.select(
|
|
23
|
+
"Database",
|
|
24
|
+
choices=[e.value for e in Database],
|
|
25
|
+
default="none",
|
|
26
|
+
).unsafe_ask()
|
|
27
|
+
|
|
28
|
+
raw_orm: str = "none"
|
|
29
|
+
if raw_database != "none":
|
|
30
|
+
raw_orm = questionary.select(
|
|
31
|
+
"Data layer (SQLModel = ORM + Pydantic, none = driver only)",
|
|
32
|
+
choices=[e.value for e in Orm],
|
|
33
|
+
default="sqlmodel",
|
|
34
|
+
).unsafe_ask()
|
|
35
|
+
|
|
36
|
+
rate_limiting: bool = questionary.confirm(
|
|
37
|
+
"Enable rate limiting",
|
|
38
|
+
default=False,
|
|
39
|
+
).unsafe_ask()
|
|
40
|
+
|
|
41
|
+
run_install: bool = questionary.confirm(
|
|
42
|
+
"Run install after setup",
|
|
43
|
+
default=True,
|
|
44
|
+
).unsafe_ask()
|
|
45
|
+
|
|
46
|
+
git: bool = questionary.confirm(
|
|
47
|
+
"Initialize git repository in the new project?",
|
|
48
|
+
default=True,
|
|
49
|
+
).unsafe_ask()
|
|
50
|
+
|
|
51
|
+
git_z: bool = False
|
|
52
|
+
if git:
|
|
53
|
+
git_z = questionary.confirm(
|
|
54
|
+
"Run git z init (git-z commit wizard)? Requires git-z — https://github.com/ejpcmac/git-z",
|
|
55
|
+
default=False,
|
|
56
|
+
).unsafe_ask()
|
|
57
|
+
|
|
58
|
+
project = Project(
|
|
59
|
+
project_name=project_name,
|
|
60
|
+
package_manager="uv",
|
|
61
|
+
use_typing=True,
|
|
62
|
+
use_ruff=True,
|
|
63
|
+
enable_cors=bool(allowed_origins),
|
|
64
|
+
allowed_origins=allowed_origins,
|
|
65
|
+
database=Database(raw_database),
|
|
66
|
+
orm=Orm(raw_orm),
|
|
67
|
+
rate_limiting=rate_limiting,
|
|
68
|
+
config=True,
|
|
69
|
+
git=git,
|
|
70
|
+
git_z=git_z,
|
|
71
|
+
run_install=run_install,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return project
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def show_summary(project: Project) -> None:
|
|
78
|
+
click.echo()
|
|
79
|
+
click.echo(f"Project : {project.project_name}")
|
|
80
|
+
click.echo(f"Package Manager : {project.package_manager}")
|
|
81
|
+
|
|
82
|
+
click.echo(f"CORS enabled : {'yes' if project.enable_cors else 'no'}")
|
|
83
|
+
if project.enable_cors:
|
|
84
|
+
click.echo(f"Allowed origins : {project.allowed_origins}")
|
|
85
|
+
|
|
86
|
+
click.echo(f"Database : {project.database.value}")
|
|
87
|
+
click.echo(f"ORM : {project.orm.value}")
|
|
88
|
+
click.echo(f"Rate limiting : {'yes' if project.rate_limiting else 'no'}")
|
|
89
|
+
click.echo(f"Run install : {'yes' if project.run_install else 'no'}")
|
|
90
|
+
click.echo(f"Git init : {'yes' if project.git else 'no'}")
|
|
91
|
+
click.echo(f"git-z init : {'yes' if project.git_z else 'no'}")
|
|
92
|
+
click.echo()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# {{ project_name }}
|
|
2
|
+
|
|
3
|
+
Projet généré avec [paststack](https://github.com/initd-fr/paststack).
|
|
4
|
+
|
|
5
|
+
## Structure
|
|
6
|
+
|
|
7
|
+
- `src/app/core/` — configuration (`pydantic-settings`)
|
|
8
|
+
- `src/app/api/` — routeurs, dépendances, routes
|
|
9
|
+
- `src/app/models/` — modèles **SQLModel** (si ORM activé)
|
|
10
|
+
- `src/app/schemas/` — schémas Pydantic pour l’API
|
|
11
|
+
- `src/app/database.py` — couche base (générée selon SQLite / Postgres, ORM ou non)
|
|
12
|
+
|
|
13
|
+
## Dépendances optionnelles
|
|
14
|
+
|
|
15
|
+
Le générateur installe les extras `uv` adaptés (ex. `sqlite-sqlmodel`, `postgres-none`, `rate-limit`).
|
|
16
|
+
Pour réinstaller à la main :
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv sync --extra <nom> # voir [project.optional-dependencies] dans pyproject.toml
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Lancer en dev
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cp .env.example .env
|
|
26
|
+
uv run uvicorn app.main:app --reload --app-dir src
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
PostgreSQL : démarre la base avec `docker compose up -d` (fichier fourni si tu as choisi Postgres).
|
|
30
|
+
|
|
31
|
+
Documentation interactive : http://127.0.0.1:8000/docs
|
|
32
|
+
|
|
33
|
+
## Qualité
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv sync --group dev
|
|
37
|
+
uv run ruff check src
|
|
38
|
+
uv run mypy src
|
|
39
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "{{ project_name }}"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "FastAPI application"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"fastapi>=0.115.0",
|
|
13
|
+
"pydantic-settings>=2.6.0",
|
|
14
|
+
"uvicorn[standard]>=0.32.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
sqlite-none = ["aiosqlite>=0.20.0"]
|
|
19
|
+
sqlite-sqlmodel = [
|
|
20
|
+
"aiosqlite>=0.20.0",
|
|
21
|
+
"sqlalchemy[asyncio]>=2.0.0",
|
|
22
|
+
"sqlmodel>=0.0.22",
|
|
23
|
+
]
|
|
24
|
+
postgres-none = ["psycopg[binary]>=3.2.0"]
|
|
25
|
+
postgres-sqlmodel = [
|
|
26
|
+
"asyncpg>=0.30.0",
|
|
27
|
+
"sqlalchemy[asyncio]>=2.0.0",
|
|
28
|
+
"sqlmodel>=0.0.22",
|
|
29
|
+
]
|
|
30
|
+
rate-limit = ["slowapi>=0.1.9"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"mypy>=1.13.0",
|
|
38
|
+
"ruff>=0.8.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
target-version = "py312"
|
|
43
|
+
line-length = 100
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "F", "I", "UP"]
|
|
47
|
+
|
|
48
|
+
[tool.mypy]
|
|
49
|
+
python_version = "3.12"
|
|
50
|
+
strict = true
|
|
51
|
+
mypy_path = "src"
|
|
52
|
+
explicit_package_bases = true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP : routeurs, endpoints et dépendances FastAPI."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Dépendances injectées dans les routes via `Depends()`."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Endpoints HTTP par domaine : un fichier (ou sous-package) par thème métier."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Endpoints `/health` et `/ready` (supervision, déploiement)."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from app.database import is_database_configured
|
|
6
|
+
|
|
7
|
+
router = APIRouter(tags=["health"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("/health")
|
|
11
|
+
def health() -> dict[str, str]:
|
|
12
|
+
return {"status": "ok"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("/ready")
|
|
16
|
+
def ready() -> dict[str, str | bool]:
|
|
17
|
+
return {"status": "ok", "database": is_database_configured()}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Réglages et constantes partagés par toute l’app."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Settings(BaseSettings):
|
|
5
|
+
"""Variables lues depuis l’environnement (fichier `.env` à la racine du projet)."""
|
|
6
|
+
|
|
7
|
+
model_config = SettingsConfigDict(
|
|
8
|
+
env_file=".env",
|
|
9
|
+
env_file_encoding="utf-8",
|
|
10
|
+
extra="ignore",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
app_name: str = "{{ project_name }}"
|
|
14
|
+
debug: bool = False
|
|
15
|
+
|
|
16
|
+
cors_origins: list[str] = {{ allowed_origins }}
|
|
17
|
+
|
|
18
|
+
# URL async (SQLite ou Postgres) ; sans base de données, la valeur n’est pas utilisée
|
|
19
|
+
database_url: str = "{{ database_url }}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
settings = Settings()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
6
|
+
|
|
7
|
+
from app.api.router import api_router
|
|
8
|
+
from app.api.routes import health
|
|
9
|
+
from app.core.config import settings
|
|
10
|
+
from app.database import lifespan_shutdown, lifespan_startup
|
|
11
|
+
|
|
12
|
+
{{ rate_limit_imports }}
|
|
13
|
+
|
|
14
|
+
@asynccontextmanager
|
|
15
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
16
|
+
await lifespan_startup()
|
|
17
|
+
yield
|
|
18
|
+
await lifespan_shutdown()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_app() -> FastAPI:
|
|
22
|
+
app = FastAPI(
|
|
23
|
+
title=settings.app_name,
|
|
24
|
+
version="0.1.0",
|
|
25
|
+
lifespan=lifespan,
|
|
26
|
+
)
|
|
27
|
+
{{ rate_limit_setup }}
|
|
28
|
+
if settings.cors_origins:
|
|
29
|
+
app.add_middleware(
|
|
30
|
+
CORSMiddleware,
|
|
31
|
+
allow_origins=settings.cors_origins,
|
|
32
|
+
allow_credentials=True,
|
|
33
|
+
allow_methods=["*"],
|
|
34
|
+
allow_headers=["*"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
app.include_router(health.router)
|
|
38
|
+
app.include_router(api_router, prefix="/api/v1")
|
|
39
|
+
return app
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
app = create_app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Modèles ORM (tables SQL) — SQLModel ou SQLAlchemy selon ton projet."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Schémas Pydantic : corps de requête, query params, réponses JSON."""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""PostgreSQL sans ORM — connexion async via psycopg 3."""
|
|
2
|
+
|
|
3
|
+
from app.core.config import settings
|
|
4
|
+
from psycopg import AsyncConnection
|
|
5
|
+
|
|
6
|
+
_conn: AsyncConnection | None = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def lifespan_startup() -> None:
|
|
10
|
+
global _conn
|
|
11
|
+
_conn = await AsyncConnection.connect(settings.database_url)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def lifespan_shutdown() -> None:
|
|
15
|
+
if _conn is not None:
|
|
16
|
+
await _conn.close()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_database_configured() -> bool:
|
|
20
|
+
return True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_connection() -> AsyncConnection:
|
|
24
|
+
if _conn is None:
|
|
25
|
+
msg = "Database not initialized"
|
|
26
|
+
raise RuntimeError(msg)
|
|
27
|
+
return _conn
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Injection d’une session SQLModel async dans les routes (`Depends(get_session)`)."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from app.database import get_session
|
|
9
|
+
|
|
10
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""PostgreSQL + SQLModel (async via asyncpg)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
6
|
+
from sqlmodel import SQLModel
|
|
7
|
+
|
|
8
|
+
from app.core.config import settings
|
|
9
|
+
|
|
10
|
+
engine: AsyncEngine = create_async_engine(
|
|
11
|
+
settings.database_url,
|
|
12
|
+
echo=settings.debug,
|
|
13
|
+
)
|
|
14
|
+
async_session_maker = async_sessionmaker(
|
|
15
|
+
engine,
|
|
16
|
+
class_=AsyncSession,
|
|
17
|
+
expire_on_commit=False,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def lifespan_startup() -> None:
|
|
22
|
+
import app.models # noqa: F401 — pour enregistrer les tables auprès de SQLModel
|
|
23
|
+
|
|
24
|
+
async with engine.begin() as conn:
|
|
25
|
+
await conn.run_sync(SQLModel.metadata.create_all)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def lifespan_shutdown() -> None:
|
|
29
|
+
await engine.dispose()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_database_configured() -> bool:
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
37
|
+
async with async_session_maker() as session:
|
|
38
|
+
yield session
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""SQLite sans ORM — connexions async via aiosqlite."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import aiosqlite
|
|
6
|
+
|
|
7
|
+
DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
|
8
|
+
DB_PATH = DATA_DIR / "app.db"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def lifespan_startup() -> None:
|
|
12
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
14
|
+
await db.execute("SELECT 1")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def lifespan_shutdown() -> None:
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_database_configured() -> bool:
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def get_connection() -> aiosqlite.Connection:
|
|
26
|
+
"""Ouvrir une connexion : `db = await get_connection()` puis `await db.close()`."""
|
|
27
|
+
return await aiosqlite.connect(DB_PATH)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Injection d’une session SQLModel async dans les routes (`Depends(get_session)`)."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from app.database import get_session
|
|
9
|
+
|
|
10
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""SQLite + SQLModel (async)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
7
|
+
from sqlmodel import SQLModel
|
|
8
|
+
|
|
9
|
+
from app.core.config import settings
|
|
10
|
+
|
|
11
|
+
engine: AsyncEngine = create_async_engine(
|
|
12
|
+
settings.database_url,
|
|
13
|
+
echo=settings.debug,
|
|
14
|
+
)
|
|
15
|
+
async_session_maker = async_sessionmaker(
|
|
16
|
+
engine,
|
|
17
|
+
class_=AsyncSession,
|
|
18
|
+
expire_on_commit=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def lifespan_startup() -> None:
|
|
23
|
+
(Path(__file__).resolve().parent.parent.parent / "data").mkdir(parents=True, exist_ok=True)
|
|
24
|
+
import app.models # noqa: F401 — pour enregistrer les tables auprès de SQLModel
|
|
25
|
+
|
|
26
|
+
async with engine.begin() as conn:
|
|
27
|
+
await conn.run_sync(SQLModel.metadata.create_all)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def lifespan_shutdown() -> None:
|
|
31
|
+
await engine.dispose()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_database_configured() -> bool:
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
39
|
+
async with async_session_maker() as session:
|
|
40
|
+
yield session
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Limite le débit des requêtes par IP (slowapi / Limiter)."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
5
|
+
from slowapi.errors import RateLimitExceeded
|
|
6
|
+
from slowapi.middleware import SlowAPIMiddleware
|
|
7
|
+
from slowapi.util import get_remote_address
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_rate_limiting(app: FastAPI) -> None:
|
|
11
|
+
limiter = Limiter(key_func=get_remote_address, default_limits=["120/minute"])
|
|
12
|
+
app.state.limiter = limiter
|
|
13
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
14
|
+
app.add_middleware(SlowAPIMiddleware)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paststack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Opinionated CLI to generate production-ready FastAPI boilerplate with typing and linting.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/initd-fr/paststack
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: click>=8.1.0
|
|
11
|
+
Requires-Dist: pydantic>=2.12.5
|
|
12
|
+
Requires-Dist: questionary>=2.1.1
|
|
13
|
+
Requires-Dist: rich>=13.0.0
|
|
14
|
+
Requires-Dist: typer>=0.24.1
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# paststack
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
A CLI to generate production-ready FastAPI backends with a clean, opinionated architecture.
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+

|
|
25
|
+

|
|
26
|
+

|
|
27
|
+

|
|
28
|
+

|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Vue d’ensemble
|
|
34
|
+
|
|
35
|
+
**paststack** génère un projet prêt au développement : arborescence `src/app/` (core, api, routes, models, schemas), configuration **pydantic-settings**, CORS, santé `/health` et `/ready`.
|
|
36
|
+
|
|
37
|
+
Décisions actuelles de la v1 :
|
|
38
|
+
|
|
39
|
+
| Sujet | Choix |
|
|
40
|
+
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| Gestionnaire de paquets | **uv** |
|
|
42
|
+
| Base de données | **none**, **SQLite**, **PostgreSQL** |
|
|
43
|
+
| Couche données | **sans ORM** (driver async) ou **SQLModel** (ORM unique) |
|
|
44
|
+
| Rate limiting | **slowapi** (optionnel), limite par IP |
|
|
45
|
+
| PostgreSQL | **docker-compose** fourni ; URL alignée sur le conteneur |
|
|
46
|
+
| Dépendances | **extras** dans le `pyproject.toml` généré (`sqlite-none`, `sqlite-sqlmodel`, `postgres-none`, `postgres-sqlmodel`, `rate-limit`) |
|
|
47
|
+
| Git | `git init` optionnel |
|
|
48
|
+
| Messages de commit | **[git-z](https://github.com/ejpcmac/git-z)** optionnel (`git z init` dans le projet généré) |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Fonctionnalités
|
|
53
|
+
|
|
54
|
+
- Assistant interactif (`questionary`) : nom du projet, CORS, base, ORM, rate limiting, installation `uv`, git, git-z
|
|
55
|
+
- Copie des templates embarqués dans le package (`templates/**/*`)
|
|
56
|
+
- Création d’un venv + `uv sync` avec les bons `--extra` si demandé
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Utilisation
|
|
61
|
+
|
|
62
|
+
Une fois le package installé (`pip install paststack` depuis PyPI, ou `uv pip install -e .` depuis ce dépôt) :
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
paststack
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Puis ouvrir le dossier créé, copier `.env.example` vers `.env`, lancer l’API (voir le `README.md` généré dans le projet).
|
|
69
|
+
|
|
70
|
+
### Développement (ce dépôt)
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git clone https://github.com/initd-fr/paststack.git
|
|
74
|
+
cd paststack
|
|
75
|
+
uv sync
|
|
76
|
+
uv pip install -e .
|
|
77
|
+
paststack
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Qualité (ce dépôt)
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv run ruff check .
|
|
84
|
+
uv run mypy .
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Tests (ce dépôt)
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv sync --group dev
|
|
91
|
+
uv run pytest tests/ -v
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Les combinaisons valides (SGBD × ORM × rate limiting) sont exposées dans `paststack.combinations` pour les tests ou un usage programmatique.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Conventions de commit (ce dépôt)
|
|
99
|
+
|
|
100
|
+
Format décrit dans `git-z.toml` : `TYPE description (scope)` (types et scopes listés dans le fichier).
|
|
101
|
+
|
|
102
|
+
Pour utiliser l’assistant [git-z](https://github.com/ejpcmac/git-z) en local : `git z init` (après installation de l’outil). Le générateur peut lancer `git z init` dans le **nouveau** projet si tu coches l’option correspondante.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Feuille de route (indicative)
|
|
107
|
+
|
|
108
|
+
### v0.x — générateur actuel
|
|
109
|
+
|
|
110
|
+
- [x] CLI interactive + modèle `Project` typé
|
|
111
|
+
- [x] Template FastAPI (`core`, `api`, routes, models, schemas)
|
|
112
|
+
- [x] SQLite / Postgres × ORM ou driver seul
|
|
113
|
+
- [x] Rate limiting (slowapi) en option
|
|
114
|
+
- [x] Venv + `uv sync` avec extras
|
|
115
|
+
- [x] `git init` / `git z init` en option
|
|
116
|
+
|
|
117
|
+
### Plus tard
|
|
118
|
+
|
|
119
|
+
- Variantes d’architecture (minimal / modulable avancée), autres SGBD, observabilité, etc.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Pourquoi ce projet
|
|
124
|
+
|
|
125
|
+
Poser une base FastAPI propre (structure, typing, lint, DB) prend du temps. Ce CLI applique les mêmes défauts à chaque nouveau service.
|
|
126
|
+
|
|
127
|
+
## Licence
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
paststack/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
paststack/banner.py,sha256=q4oXk0FY5zVm6WXmnfYbKd7u2xMVnhtQ3u3iq0lwkck,1063
|
|
3
|
+
paststack/cli.py,sha256=GYSRgqji1BNQLr3h7Zf8Bl9EFhUYatrf4La7MyutmP8,11944
|
|
4
|
+
paststack/combinations.py,sha256=KO9kSbd0a77LvJjBQIqPLK_1g1DWm0aKP_knZSnBYjM,1986
|
|
5
|
+
paststack/models.py,sha256=-GnOSgXelxBgl_OmXGRY65RwQ2dtKgL4QvGOiz0Nx9M,528
|
|
6
|
+
paststack/prompts.py,sha256=JpCZPCvRDpU_MmRaaN6AqzK77L4wI08jRALytTFKyA0,2743
|
|
7
|
+
paststack/templates/base/.env.example,sha256=-Rvx9TNbGbAkGduAaRd8U9jh4b_7TIqNmTdHwE9rRvk,55
|
|
8
|
+
paststack/templates/base/.gitignore,sha256=c3xgCgkYf30P98o6lA1AS_N9JwyMYkD65OvToNnIlNA,145
|
|
9
|
+
paststack/templates/base/README.md,sha256=A66RZhAY23hhZaFhrigOdWJQazsa-S28tKcExFfAcdU,1040
|
|
10
|
+
paststack/templates/base/pyproject.toml,sha256=qiud3FMRJBzJaUMcf_BJ06hmgBXX6R0BkG4ld-kcqSw,1011
|
|
11
|
+
paststack/templates/base/src/app/__init__.py,sha256=HKzpEnD18-CnTkVK7Ldxc0yRVzSC0ChpFcCxMPaDh0U,138
|
|
12
|
+
paststack/templates/base/src/app/database.py,sha256=3mfMGpFPDoufaD_GEP-M-DKV-CJVJ-ukIw_RplH1Sko,246
|
|
13
|
+
paststack/templates/base/src/app/main.py,sha256=uUW_QgaGlEK9b50HRGdg411d0mGngyowqmwECk26eUk,1055
|
|
14
|
+
paststack/templates/base/src/app/api/__init__.py,sha256=1i_0TYs6aJPs-0m5KLXwAeGX1-fFTyAny6G1wjhc_Dg,58
|
|
15
|
+
paststack/templates/base/src/app/api/deps.py,sha256=onq0VM_Ek53mWoV4UoWq25ZEjm8urh8eZjlM0p5OTuA,63
|
|
16
|
+
paststack/templates/base/src/app/api/router.py,sha256=VDYoTr8Z7JNJAKY_8I8IMtaUrId3X_1wUNS9CiybRMU,219
|
|
17
|
+
paststack/templates/base/src/app/api/routes/__init__.py,sha256=Lz-jtC92Bm2pwWYlCb0su-_eZ8ygJwLWnVqMPCcymFo,84
|
|
18
|
+
paststack/templates/base/src/app/api/routes/health.py,sha256=B_SQRBXH2vi12pYIBaBm8hZwAlHdv_mcAgeaHyGWlhU,397
|
|
19
|
+
paststack/templates/base/src/app/core/__init__.py,sha256=_CEqFxRWNQfJz_coVMRcYqAqhgsOUVSCxEqXWx-2Fj4,59
|
|
20
|
+
paststack/templates/base/src/app/core/config.py,sha256=CqTM6Y4Xg3a_hi4JOrfh5IPkmtfWhVJmRX0JEjSzfiw,599
|
|
21
|
+
paststack/templates/base/src/app/models/__init__.py,sha256=aNCTsy0qEpHNwdsLcwlJGSuKZUR0hhCP9b0nYQu8itc,77
|
|
22
|
+
paststack/templates/base/src/app/schemas/__init__.py,sha256=Bjnprg3q75H3nplHydYz6gk6d-FDxB68erOeVga5hU4,75
|
|
23
|
+
paststack/templates/database/postgres/docker-compose.yml,sha256=E7Gle_6GZN03hacGBwSploHOEpFfwtl3kEi5ihKUlGY,258
|
|
24
|
+
paststack/templates/database/postgres/none/database.py,sha256=r4iEpHH7ry4bPeX7ltKfDQwRo6X26Z_kgG1QF7LWrgM,601
|
|
25
|
+
paststack/templates/database/postgres/sqlmodel/database.py,sha256=1qx9htyCGrqTtBLY51E29wT2l4MVzoBtqpetVLplALM,957
|
|
26
|
+
paststack/templates/database/postgres/sqlmodel/api/deps.py,sha256=BOojAC9sQnbsO153QGZI24DS3KVVZwywGStVDMpyIlI,294
|
|
27
|
+
paststack/templates/database/postgres/sqlmodel/models/__init__.py,sha256=2oYxJudsBFH4so4CNw8L_3CGT1BG4qomB9y-zes8BAE,133
|
|
28
|
+
paststack/templates/database/sqlite/none/database.py,sha256=xlT4mHcA2MLMY-OtTE_1MAGMWiH3Iy3lfVnuFw8hyEE,667
|
|
29
|
+
paststack/templates/database/sqlite/sqlmodel/database.py,sha256=ZUTiAUfBv3GrcFK2AzY7yLkgxHVe8BZ64z8ctW8tHDQ,1062
|
|
30
|
+
paststack/templates/database/sqlite/sqlmodel/api/deps.py,sha256=BOojAC9sQnbsO153QGZI24DS3KVVZwywGStVDMpyIlI,294
|
|
31
|
+
paststack/templates/database/sqlite/sqlmodel/models/__init__.py,sha256=2oYxJudsBFH4so4CNw8L_3CGT1BG4qomB9y-zes8BAE,133
|
|
32
|
+
paststack/templates/rate_limiting/src/app/core/rate_limit.py,sha256=QcdS7Ny6tprNw0CTQy6trtdLneYTCvHm6Qic0bubp1I,574
|
|
33
|
+
paststack-0.1.0.dist-info/licenses/LICENSE,sha256=kfpGx5BL8MZ6FculLtl3jKSa22TPkZKNpaTThxj-Yaw,1080
|
|
34
|
+
paststack-0.1.0.dist-info/METADATA,sha256=AqqPnoRXk2z9o17aWEsO9QxeVbbquqALPCGy98SnJ7A,4979
|
|
35
|
+
paststack-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
36
|
+
paststack-0.1.0.dist-info/entry_points.txt,sha256=Ikb6kTNbasiFCF7fd0HbpH6Dv-7DCh47YFpDlaNSvkQ,49
|
|
37
|
+
paststack-0.1.0.dist-info/top_level.txt,sha256=zKdgU3u1iisr9qqlj2H8cXpy71NgmGjmZfRhBnZ6E9c,10
|
|
38
|
+
paststack-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quentin Van Steenwinkel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
paststack
|