feat(infra): cookiecutter templates backend fullstack cli (#235)
* feat(infra): backend cookiecutter template with fastapi tortoise * feat(infra): cli cookiecutter template with typer entry point * feat(infra): fullstack cookiecutter template with sveltekit frontend * test(infra): cookiecutter template tests and render fixes * style(infra): ruff cleanup for cookiecutter template tests * style(infra): ruff format cookiecutter template tests * fix(infra): handle use_db no use_auth no in cookiecutter --------- Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
parent
42c8e2d31e
commit
35b6c6e542
140 changed files with 3992 additions and 0 deletions
8
.opencode/templates/backend/cookiecutter.json
Normal file
8
.opencode/templates/backend/cookiecutter.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"project_name": "my-project",
|
||||||
|
"project_type": "backend",
|
||||||
|
"description": "Project description",
|
||||||
|
"use_auth": ["no", "yes"],
|
||||||
|
"use_db": ["yes", "no"],
|
||||||
|
"python_version": "3.13"
|
||||||
|
}
|
||||||
60
.opencode/templates/backend/hooks/post_gen_project.py
Normal file
60
.opencode/templates/backend/hooks/post_gen_project.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Post-generation hook for the backend cookiecutter template.
|
||||||
|
|
||||||
|
Removes files that are conditional on the ``use_auth`` and ``use_db`` flags
|
||||||
|
so the rendered tree only contains the parts the user asked for.
|
||||||
|
|
||||||
|
- ``use_auth == "no"`` -> drop ``routes/auth.py``, ``services/auth_service.py``,
|
||||||
|
``models/user.py`` (hashed_password), and strip the auth dependency wiring.
|
||||||
|
- ``use_db == "no"`` -> drop ``db/``, ``migrations/``, ``connection.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_DIR = Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def _remove(path: str) -> None:
|
||||||
|
"""Remove a file or directory relative to the generated project root."""
|
||||||
|
p = PROJECT_DIR / path
|
||||||
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
|
elif p.exists():
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
use_auth = "{{ cookiecutter.use_auth }}"
|
||||||
|
use_db = "{{ cookiecutter.use_db }}"
|
||||||
|
pkg = "{{ cookiecutter.project_name }}"
|
||||||
|
|
||||||
|
if use_auth == "no":
|
||||||
|
_remove(f"src/{pkg}/api/v1/routes/auth.py")
|
||||||
|
_remove(f"src/{pkg}/services/auth_service.py")
|
||||||
|
_remove(f"tests/test_auth.py")
|
||||||
|
|
||||||
|
if use_db == "no":
|
||||||
|
# db is the root cause for the broken-conditional findings: files
|
||||||
|
# with unconditional ``from ...db.models.user import User`` must be
|
||||||
|
# stripped together with db/, otherwise the generated project
|
||||||
|
# fails to import (ImportError/NameError on startup).
|
||||||
|
_remove(f"src/{pkg}/db")
|
||||||
|
_remove("migrations")
|
||||||
|
_remove(f"src/{pkg}/services/user_service.py")
|
||||||
|
_remove(f"src/{pkg}/api/v1/routes/users.py")
|
||||||
|
_remove(f"src/{pkg}/api/v1/dependencies.py")
|
||||||
|
_remove(f"src/{pkg}/schemas/user.py")
|
||||||
|
_remove(f"tests/unit/test_user.py")
|
||||||
|
_remove(f"tests/unit/test_user_service.py")
|
||||||
|
_remove(f"tests/api/test_users.py")
|
||||||
|
if use_auth == "yes":
|
||||||
|
# auth_service imports User; strip it and its wiring too.
|
||||||
|
_remove(f"src/{pkg}/api/v1/routes/auth.py")
|
||||||
|
_remove(f"src/{pkg}/services/auth_service.py")
|
||||||
|
_remove(f"tests/test_auth.py")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
.opencode/templates/backend/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
10
.opencode/templates/backend/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
42
.opencode/templates/backend/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
42
.opencode/templates/backend/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run ruff check .
|
||||||
|
- run: uv run ruff format --check .
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run mypy src tests
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run pytest
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv build
|
||||||
18
.opencode/templates/backend/{{cookiecutter.project_name}}/.gitignore
vendored
Normal file
18
.opencode/templates/backend/{{cookiecutter.project_name}}/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.5.0
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.10.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
additional_dependencies: [pydantic-settings]
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.6.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-added-large-files
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{{ cookiecutter.python_version }}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) {% now 'utc', '%Y' %} slaid098
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
# 🚀 {{ cookiecutter.project_name }}
|
||||||
|
|
||||||
|
> Language switcher: **[English](#-english)** | **[Русский](#-русский)**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<!-- tagline-en:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- tagline-en:end -->
|
||||||
|
|
||||||
|
<!-- summary-en:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- summary-en:end -->
|
||||||
|
|
||||||
|
## 🇺🇸 English
|
||||||
|
|
||||||
|
<!-- features-en:start -->
|
||||||
|
- FastAPI backend with Tortoise ORM
|
||||||
|
- Pydantic-settings configuration
|
||||||
|
- Ruff + mypy strict + pytest
|
||||||
|
- Pre-commit hooks
|
||||||
|
<!-- features-en:end -->
|
||||||
|
|
||||||
|
### ⚡ Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --extra dev
|
||||||
|
uv run uvicorn main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Support and contacts / Поддержка и контакты
|
||||||
|
|
||||||
|
👉 **[slaid098.dev/support](https://slaid098.dev/support)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🇷🇺 Русский
|
||||||
|
|
||||||
|
<!-- tagline-ru:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- tagline-ru:end -->
|
||||||
|
|
||||||
|
<!-- summary-ru:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- summary-ru:end -->
|
||||||
|
|
||||||
|
<!-- features-ru:start -->
|
||||||
|
- Бэкенд на FastAPI с Tortoise ORM
|
||||||
|
- Конфигурация через pydantic-settings
|
||||||
|
- Ruff + mypy strict + pytest
|
||||||
|
- Pre-commit хуки
|
||||||
|
<!-- features-ru:end -->
|
||||||
|
|
||||||
|
### ⚡ Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --extra dev
|
||||||
|
uv run uvicorn main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Support and contacts / Поддержка и контакты
|
||||||
|
|
||||||
|
👉 **[slaid098.dev/support](https://slaid098.dev/support)**
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Application
|
||||||
|
APP__ENVIRONMENT=dev
|
||||||
|
SERVER__HOST=0.0.0.0
|
||||||
|
SERVER__PORT=8000
|
||||||
|
|
||||||
|
# Database (nested via __ separator — pydantic-settings convention)
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
DATABASE__URL=sqlite://db.sqlite3
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
# Auth
|
||||||
|
JWT__SECRET=change-me-in-production
|
||||||
|
JWT__ALGORITHM=HS256
|
||||||
|
JWT__EXPIRE_MINUTES=60
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
# IP whitelist (no hardcoded production IPs — override in production)
|
||||||
|
IP_WHITELIST=["127.0.0.1", "::1"]
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""FastAPI application entry point for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.logger import setup_logging
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.db.connection import close_db, init_db
|
||||||
|
{% endif %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.router import api_router
|
||||||
|
|
||||||
|
_BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
{% if cookiecutter.use_db == "yes" %}setup_logging()
|
||||||
|
await init_db()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await close_db(){% else %}setup_logging()
|
||||||
|
yield{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
static_dir = _BASE_DIR / "static"
|
||||||
|
static_dir.mkdir(exist_ok=True)
|
||||||
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Tortoise migrations directory
|
||||||
|
|
||||||
|
This directory holds migration files generated by the built-in Tortoise
|
||||||
|
migrator. Run:
|
||||||
|
|
||||||
|
python -m tortoise.migrator makemigrations
|
||||||
|
|
||||||
|
Generated files land here and are committed to git.
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "{{ cookiecutter.project_name }}"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "{{ cookiecutter.description }}"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
requires-python = ">={{ cookiecutter.python_version }}"
|
||||||
|
authors = [{ name = "slaid098" }]
|
||||||
|
keywords = []
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastapi",
|
||||||
|
"uvicorn[standard]",
|
||||||
|
"pydantic-settings",
|
||||||
|
"loguru",
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
"tortoise-orm",
|
||||||
|
"asyncpg",
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
"passlib[bcrypt]",
|
||||||
|
"pyjwt",
|
||||||
|
{% endif %}
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=5.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"pytest-timeout>=2.2",
|
||||||
|
"httpx",
|
||||||
|
"mypy>=1.10",
|
||||||
|
"ruff>=0.5",
|
||||||
|
"pre-commit>=3.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
|
Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
|
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
|
|
||||||
|
# ── Ruff ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py313"
|
||||||
|
line-length = 100
|
||||||
|
src = ["src", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", "W",
|
||||||
|
"F",
|
||||||
|
"I",
|
||||||
|
"B",
|
||||||
|
"UP",
|
||||||
|
"SIM",
|
||||||
|
"C90",
|
||||||
|
"PL",
|
||||||
|
"RUF",
|
||||||
|
"S",
|
||||||
|
"TRY",
|
||||||
|
"LOG",
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"S101",
|
||||||
|
"S311",
|
||||||
|
"RUF001",
|
||||||
|
"RUF002",
|
||||||
|
"RUF003",
|
||||||
|
"TRY003",
|
||||||
|
"PLR2004",
|
||||||
|
"S106",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.mccabe]
|
||||||
|
max-complexity = 10
|
||||||
|
|
||||||
|
[tool.ruff.lint.pylint]
|
||||||
|
max-args = 5
|
||||||
|
max-branches = 12
|
||||||
|
max-returns = 5
|
||||||
|
max-statements = 50
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607", "PLR0913"]
|
||||||
|
|
||||||
|
# ── mypy ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "{{ cookiecutter.python_version }}"
|
||||||
|
strict = true
|
||||||
|
explicit_package_bases = true
|
||||||
|
warn_return_any = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
|
||||||
|
# ── pytest ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
addopts = "--cov=src --cov-report=term-missing --timeout=120"
|
||||||
|
|
||||||
|
# ── coverage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── project-status ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.project-status]
|
||||||
|
route_line_limit = 50
|
||||||
|
min_test_count = 1
|
||||||
|
require_branch_protection = false
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""{{ cookiecutter.project_name }} package."""
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""API package for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.router import api_router # noqa: F401
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""API router aggregation for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.router import v1_router
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(v1_router, prefix="/v1")
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""v1 API package."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.router import v1_router # noqa: F401
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Shared dependencies for v1 routes.
|
||||||
|
|
||||||
|
``check_ip_whitelist`` is always present; ``get_current_user`` is wired
|
||||||
|
only when ``use_auth == "yes``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings
|
||||||
|
|
||||||
|
DEFAULT_WHITELIST = ["127.0.0.1", "::1"]
|
||||||
|
|
||||||
|
|
||||||
|
async def check_ip_whitelist(request: Request) -> None:
|
||||||
|
"""Reject requests from non-whitelisted IPs.
|
||||||
|
|
||||||
|
Defaults to ``["127.0.0.1", "::1"]`` (no hardcoded production IPs);
|
||||||
|
override via ``IP_WHITELIST`` in env.
|
||||||
|
"""
|
||||||
|
client = request.client.host if request.client else None
|
||||||
|
whitelist = settings.ip_whitelist or DEFAULT_WHITELIST
|
||||||
|
if client and client not in whitelist:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"IP {client} not allowed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
|
||||||
|
|
||||||
|
_bearer = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
|
||||||
|
auth: AuthService = Depends(AuthService),
|
||||||
|
) -> str:
|
||||||
|
"""Resolve the current user from the bearer token (JWT).
|
||||||
|
|
||||||
|
Returns the username/subject of the token. Only present when
|
||||||
|
``use_auth == "yes"``.
|
||||||
|
"""
|
||||||
|
return await auth.get_current_user(credentials.credentials)
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Versioned router (v1) for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.dependencies import check_ip_whitelist
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import users
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import auth
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
v1_router = APIRouter(dependencies=[Depends(check_ip_whitelist)])
|
||||||
|
v1_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||||
|
{% else %}
|
||||||
|
v1_router = APIRouter()
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
v1_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""Routes package for v1."""
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import users # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import auth # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Auth routes — /login, /register (only when use_auth=yes)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.user import Token, UserCreate, UserLogin, UserResponse
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse, status_code=201)
|
||||||
|
async def register(payload: UserCreate) -> UserResponse:
|
||||||
|
"""Register a new user — returns the public profile."""
|
||||||
|
user = await AuthService.register(payload.username, payload.email, payload.password)
|
||||||
|
return UserResponse(id=user.id, username=user.username, email=user.email)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(payload: UserLogin) -> Token:
|
||||||
|
"""Login with username + password — returns a JWT."""
|
||||||
|
access_token = await AuthService.login(payload.username, payload.password)
|
||||||
|
return Token(access_token=access_token)
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""User routes — thin handlers (≤50 lines), delegate to services."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.user import UserResponse
|
||||||
|
from {{ cookiecutter.project_name }}.services.user_service import UserService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserResponse])
|
||||||
|
async def list_users() -> list[UserResponse]:
|
||||||
|
"""List users — thin handler, business logic lives in the service."""
|
||||||
|
users = await UserService.get_users()
|
||||||
|
return [UserResponse.model_validate(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
|
async def get_user(user_id: int) -> UserResponse:
|
||||||
|
"""Get a single user by id — thin handler."""
|
||||||
|
user = await UserService.get_user(user_id)
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Configuration package — settings + logger."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings # noqa: F401
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""Loguru logging setup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Configure loguru sink — remove default handler, add stdout."""
|
||||||
|
logger.remove()
|
||||||
|
logger.add(
|
||||||
|
sys.stdout,
|
||||||
|
level="INFO",
|
||||||
|
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level}</level> | {message}",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Application settings via pydantic-settings.
|
||||||
|
|
||||||
|
Nested fields use the ``__`` separator (pydantic-settings convention):
|
||||||
|
``DATABASE__URL`` -> ``settings.database.url``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Environment(StrEnum):
|
||||||
|
DEV = "dev"
|
||||||
|
STAGING = "staging"
|
||||||
|
PROD = "prod"
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSettings(BaseSettings):
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
class DatabaseSettings(BaseSettings):
|
||||||
|
url: str = "sqlite://db.sqlite3"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
class JWTSettings(BaseSettings):
|
||||||
|
secret: str = "change-me-in-production"
|
||||||
|
algorithm: str = "HS256"
|
||||||
|
expire_minutes: int = 60
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
environment: Environment = Environment.DEV
|
||||||
|
server: ServerSettings = Field(default_factory=ServerSettings)
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
jwt: JWTSettings = Field(default_factory=JWTSettings)
|
||||||
|
{% endif %}
|
||||||
|
ip_whitelist: list[str] = Field(default_factory=lambda: ["127.0.0.1", "::1"])
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% if cookiecutter.use_db == "yes" %}"""DB package — Tortoise ORM connection + models."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.db.connection import close_db, init_db # noqa: F401
|
||||||
|
{% else %}"""DB package (disabled — use_db=no)."""
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Tortoise ORM connection setup.
|
||||||
|
|
||||||
|
Uses ``Path(__file__)``-relative paths (no CWD reliance) so the app is
|
||||||
|
portable regardless of the working directory it is launched from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tortoise import Tortoise
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings
|
||||||
|
|
||||||
|
_MODELS_PATH = "src.{{ cookiecutter.project_name }}.db.models"
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
"""Initialize Tortoise with generate_schemas (built-in, NOT Aerich)."""
|
||||||
|
await Tortoise.init(
|
||||||
|
db_url=settings.database.url,
|
||||||
|
modules={"models": [_MODELS_PATH]},
|
||||||
|
)
|
||||||
|
await Tortoise.generate_schemas(safe=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db() -> None:
|
||||||
|
"""Close all Tortoise connections."""
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Tortoise ORM models."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User # noqa: F401
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""User model.
|
||||||
|
|
||||||
|
When ``use_auth == "yes"`` the ``hashed_password`` field is present;
|
||||||
|
otherwise the model holds only the public profile fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
class User(Model):
|
||||||
|
id = fields.IntField(pk=True)
|
||||||
|
username = fields.CharField(max_length=64, unique=True)
|
||||||
|
email = fields.CharField(max_length=128, unique=True)
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
hashed_password = fields.CharField(max_length=128)
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "users"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.username
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""Pydantic schemas."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.base import MetaResponse, PaginatedResponse # noqa: F401
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.user import UserResponse # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Base schemas — shared response envelopes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class MetaResponse(BaseModel):
|
||||||
|
"""Standard meta block for API responses."""
|
||||||
|
|
||||||
|
total: int
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseModel, Generic[T]):
|
||||||
|
"""Generic paginated envelope: ``{items, meta}``."""
|
||||||
|
|
||||||
|
items: list[T]
|
||||||
|
meta: MetaResponse
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""User schemas.
|
||||||
|
|
||||||
|
When ``use_auth == "yes"`` the auth-related schemas (``UserCreate``,
|
||||||
|
``UserLogin``, ``Token``) are added; ``UserResponse`` and ``UserInput``
|
||||||
|
are always present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserInput(BaseModel):
|
||||||
|
"""Input payload for creating a user."""
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=64)
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""Public user representation (never leaks the password)."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
"""Registration payload — username, email, plain password."""
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=64)
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
"""Login payload — username + plain password."""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
"""JWT response envelope."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""Services package — business logic (Tortoise queries)."""
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.services.user_service import UserService # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Auth service — JWT issuance + verification (passlib[bcrypt] + pyjwt).
|
||||||
|
|
||||||
|
Only rendered when ``use_auth == "yes"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User
|
||||||
|
|
||||||
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""Stateless auth service — JWT + password hashing."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _pwd.hash(password)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return _pwd.verify(plain, hashed)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_token(username: str) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=settings.jwt.expire_minutes,
|
||||||
|
)
|
||||||
|
payload: dict[str, Any] = {"sub": username, "exp": expire}
|
||||||
|
return jwt.encode(payload, settings.jwt.secret, algorithm=settings.jwt.algorithm)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def register(username: str, email: str, password: str) -> User:
|
||||||
|
hashed = AuthService.hash_password(password)
|
||||||
|
return await User.create(
|
||||||
|
username=username, email=email, hashed_password=hashed,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def login(username: str, password: str) -> str:
|
||||||
|
user = await User.get_or_none(username=username)
|
||||||
|
if user is None or not AuthService.verify_password(password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid credentials",
|
||||||
|
)
|
||||||
|
return AuthService.create_token(username)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_current_user(token: str) -> str:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token, settings.jwt.secret, algorithms=[settings.jwt.algorithm],
|
||||||
|
)
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token",
|
||||||
|
) from exc
|
||||||
|
return str(payload.get("sub", ""))
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""User service — business logic (Tortoise queries).
|
||||||
|
|
||||||
|
Fix from slaid098/templates: ``get_users`` returns a plain ``list`` of
|
||||||
|
User instances (the old implementation returned a tuple while every caller
|
||||||
|
expected a list).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserService:
|
||||||
|
"""Stateless user service — all methods are async classmethods."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_users() -> list[User]:
|
||||||
|
"""Return all users as a list (NOT a tuple — bug fixed)."""
|
||||||
|
return list(await User.all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user(user_id: int) -> User:
|
||||||
|
"""Return a single user by id or raise 404."""
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"User {user_id} not found",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_user(username: str, email: str) -> User:
|
||||||
|
"""Create a new user."""
|
||||||
|
return await User.create(username=username, email=email)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Utils package.
|
||||||
|
|
||||||
|
NOTE: ProjectMetadata lives ONLY in ``metadata.py`` — no duplication in
|
||||||
|
``__init__.py`` (fix from slaid098/templates).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.utils.metadata import ProjectMetadata # noqa: F401
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Project metadata read from ``pyproject.toml`` (single source of truth).
|
||||||
|
|
||||||
|
No duplication — callers import from here, never re-declare the metadata.
|
||||||
|
Uses ``Path(__file__)`` to locate ``pyproject.toml`` regardless of CWD.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_PYPROJECT = Path(__file__).resolve().parents[3] / "pyproject.toml"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProjectMetadata:
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
def load_metadata() -> ProjectMetadata:
|
||||||
|
"""Load metadata from ``pyproject.toml`` (single source of truth)."""
|
||||||
|
with _PYPROJECT.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
project = data.get("project", {})
|
||||||
|
return ProjectMetadata(
|
||||||
|
name=str(project.get("name", "")),
|
||||||
|
version=str(project.get("version", "")),
|
||||||
|
description=str(project.get("description", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
metadata = load_metadata()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Tests package."""
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""API tests package."""
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""API tests for user routes (thin handlers → services)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
|
||||||
|
async def test_list_users(client) -> None:
|
||||||
|
"""GET /api/v1/users returns a list of users."""
|
||||||
|
response = await client.get("/api/v1/users")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert isinstance(response.json(), list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
|
||||||
|
async def test_ip_whitelist_blocks(client) -> None:
|
||||||
|
"""Non-whitelisted IPs must get 403."""
|
||||||
|
response = await client.get("/api/v1/users", headers={"X-Forwarded-For": "10.0.0.1"})
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Pytest configuration — fixtures shared across all tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_settings(monkeypatch) -> None:
|
||||||
|
"""Override settings with safe test defaults (no real DB/SMTP/etc)."""
|
||||||
|
monkeypatch.setattr(settings, "environment", "dev")
|
||||||
|
monkeypatch.setattr(settings, "ip_whitelist", ["127.0.0.1", "::1"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client() -> AsyncIterator[AsyncClient]:
|
||||||
|
"""HTTPX AsyncClient bound to the FastAPI app (no network)."""
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
@pytest.fixture
|
||||||
|
async def create_user() -> Any:
|
||||||
|
"""Factory: create a user in the test DB."""
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
|
||||||
|
|
||||||
|
async def _create(username: str = "tester", password: str = "password123") -> User:
|
||||||
|
hashed = AuthService.hash_password(password)
|
||||||
|
return await User.create(username=username, email=f"{username}@test.local", hashed_password=hashed)
|
||||||
|
|
||||||
|
return _create
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def auth_client(create_user) -> AsyncIterator[AsyncClient]:
|
||||||
|
"""HTTPX client with a valid bearer token pre-set."""
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
await create_user()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
resp = await ac.post("/api/v1/auth/login", json={"username": "tester", "password": "password123"})
|
||||||
|
token = resp.json()["access_token"]
|
||||||
|
ac.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
yield ac
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Integration tests package."""
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Integration tests — skip by default unless real credentials are set.
|
||||||
|
|
||||||
|
``pytestmark`` = [pytest.mark.integration, skipif no creds] — these only
|
||||||
|
run when an explicit env var is set (e.g. ``RUN_INTEGRATION=1``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.integration,
|
||||||
|
pytest.mark.skipif(
|
||||||
|
not os.getenv("RUN_INTEGRATION"),
|
||||||
|
reason="no real external credentials — set RUN_INTEGRATION=1 to enable",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_real_external_placeholder() -> None:
|
||||||
|
"""Placeholder — replace with a real external service call."""
|
||||||
|
assert True
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Auth flow tests — /register, /login (only when use_auth=yes)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
|
||||||
|
async def test_register(client) -> None:
|
||||||
|
"""POST /api/v1/auth/register creates a user."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"username": "newuser", "email": "new@test.local", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
|
||||||
|
async def test_login_returns_token(client) -> None:
|
||||||
|
"""POST /api/v1/auth/login returns a JWT."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "tester", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "access_token" in response.json()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Unit tests package."""
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Unit tests for the User model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_is_not_plain() -> None:
|
||||||
|
"""hash_password must not store the plain text."""
|
||||||
|
hashed = AuthService.hash_password("mypassword")
|
||||||
|
assert hashed != "mypassword"
|
||||||
|
assert hashed.startswith("$2") # bcrypt prefix
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_roundtrip() -> None:
|
||||||
|
"""verify_password must accept the correct password."""
|
||||||
|
hashed = AuthService.hash_password("mypassword")
|
||||||
|
assert AuthService.verify_password("mypassword", hashed) is True
|
||||||
|
assert AuthService.verify_password("wrong", hashed) is False
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_model_table_name() -> None:
|
||||||
|
"""User model must use the 'users' table."""
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User
|
||||||
|
|
||||||
|
assert User._meta.db_table == "users"
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""Unit tests for UserService (business logic, not HTTP)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.services.user_service import UserService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
|
||||||
|
async def test_get_users_returns_list() -> None:
|
||||||
|
"""get_users must return a list (NOT a tuple — bug fixed)."""
|
||||||
|
users = await UserService.get_users()
|
||||||
|
assert isinstance(users, list)
|
||||||
8
.opencode/templates/cli/cookiecutter.json
Normal file
8
.opencode/templates/cli/cookiecutter.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"project_name": "my-cli",
|
||||||
|
"project_type": "cli",
|
||||||
|
"description": "CLI tool description",
|
||||||
|
"use_auth": ["no", "yes"],
|
||||||
|
"use_db": ["yes", "no"],
|
||||||
|
"python_version": "3.13"
|
||||||
|
}
|
||||||
31
.opencode/templates/cli/hooks/post_gen_project.py
Normal file
31
.opencode/templates/cli/hooks/post_gen_project.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Post-generation hook for the cli cookiecutter template.
|
||||||
|
|
||||||
|
The cli template is unconditional (no use_auth/use_db flags affect it),
|
||||||
|
but the hook is kept for parity with backend/fullstack so the same
|
||||||
|
contract applies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_DIR = Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def _remove(path: str) -> None:
|
||||||
|
"""Remove a file or directory relative to the generated project root."""
|
||||||
|
p = PROJECT_DIR / path
|
||||||
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
|
elif p.exists():
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""No conditional files for the cli template yet — placeholder."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
.opencode/templates/cli/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
10
.opencode/templates/cli/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
42
.opencode/templates/cli/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
42
.opencode/templates/cli/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run ruff check .
|
||||||
|
- run: uv run ruff format --check .
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run mypy src tests
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run pytest
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv build
|
||||||
16
.opencode/templates/cli/{{cookiecutter.project_name}}/.gitignore
vendored
Normal file
16
.opencode/templates/cli/{{cookiecutter.project_name}}/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.5.0
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.10.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.6.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-added-large-files
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{{ cookiecutter.python_version }}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) {% now 'utc', '%Y' %} slaid098
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# 🚀 {{ cookiecutter.project_name }}
|
||||||
|
|
||||||
|
> Language switcher: **[English](#-english)** | **[Русский](#-русский)**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<!-- tagline-en:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- tagline-en:end -->
|
||||||
|
|
||||||
|
<!-- summary-en:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- summary-en:end -->
|
||||||
|
|
||||||
|
## 🇺🇸 English
|
||||||
|
|
||||||
|
<!-- features-en:start -->
|
||||||
|
- Typer-based CLI with rich output
|
||||||
|
- Ruff + mypy strict + pytest
|
||||||
|
- Pre-commit hooks
|
||||||
|
<!-- features-en:end -->
|
||||||
|
|
||||||
|
### ⚡ Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --extra dev
|
||||||
|
{{ cookiecutter.project_name }} --help
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Support and contacts / Поддержка и контакты
|
||||||
|
|
||||||
|
👉 **[slaid098.dev/support](https://slaid098.dev/support)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🇷🇺 Русский
|
||||||
|
|
||||||
|
<!-- tagline-ru:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- tagline-ru:end -->
|
||||||
|
|
||||||
|
<!-- summary-ru:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- summary-ru:end -->
|
||||||
|
|
||||||
|
<!-- features-ru:start -->
|
||||||
|
- CLI на Typer с rich-выводом
|
||||||
|
- Ruff + mypy strict + pytest
|
||||||
|
- Pre-commit хуки
|
||||||
|
<!-- features-ru:end -->
|
||||||
|
|
||||||
|
### ⚡ Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --extra dev
|
||||||
|
{{ cookiecutter.project_name }} --help
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Support and contacts / Поддержка и контакты
|
||||||
|
|
||||||
|
👉 **[slaid098.dev/support](https://slaid098.dev/support)**
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "{{ cookiecutter.project_name }}"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "{{ cookiecutter.description }}"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
requires-python = ">={{ cookiecutter.python_version }}"
|
||||||
|
authors = [{ name = "slaid098" }]
|
||||||
|
keywords = ["cli"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Environment :: Console",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"typer>=0.12",
|
||||||
|
"rich",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=5.0",
|
||||||
|
"pytest-timeout>=2.2",
|
||||||
|
"mypy>=1.10",
|
||||||
|
"ruff>=0.5",
|
||||||
|
"pre-commit>=3.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
{{ cookiecutter.project_name }} = "{{ cookiecutter.project_name }}.__main__:app"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
|
Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
|
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
|
|
||||||
|
# ── Ruff ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py313"
|
||||||
|
line-length = 100
|
||||||
|
src = ["src", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", "W",
|
||||||
|
"F",
|
||||||
|
"I",
|
||||||
|
"B",
|
||||||
|
"UP",
|
||||||
|
"SIM",
|
||||||
|
"C90",
|
||||||
|
"PL",
|
||||||
|
"RUF",
|
||||||
|
"S",
|
||||||
|
"TRY",
|
||||||
|
"LOG",
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"S101",
|
||||||
|
"S311",
|
||||||
|
"RUF001",
|
||||||
|
"RUF002",
|
||||||
|
"RUF003",
|
||||||
|
"TRY003",
|
||||||
|
"PLR2004",
|
||||||
|
"S106",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.mccabe]
|
||||||
|
max-complexity = 10
|
||||||
|
|
||||||
|
[tool.ruff.lint.pylint]
|
||||||
|
max-args = 5
|
||||||
|
max-branches = 12
|
||||||
|
max-returns = 5
|
||||||
|
max-statements = 50
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607", "PLR0913"]
|
||||||
|
|
||||||
|
# ── mypy ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "{{ cookiecutter.python_version }}"
|
||||||
|
strict = true
|
||||||
|
explicit_package_bases = true
|
||||||
|
warn_return_any = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
|
||||||
|
# ── pytest ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "--cov=src --cov-report=term-missing --timeout=120"
|
||||||
|
|
||||||
|
# ── coverage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── project-status ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.project-status]
|
||||||
|
route_line_limit = 50
|
||||||
|
min_test_count = 1
|
||||||
|
require_branch_protection = false
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""{{ cookiecutter.project_name }} package."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.core import __version__ # noqa: F401
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""CLI entry point — ``python -m {{ cookiecutter.project_name }}``."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.cli import app
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Typer CLI commands for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.core import greet, __version__
|
||||||
|
|
||||||
|
app = typer.Typer(help="{{ cookiecutter.description }}", no_args_is_help=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def hello(name: str = typer.Argument("world", help="Name to greet")) -> None:
|
||||||
|
"""Print a greeting for the given name."""
|
||||||
|
typer.echo(greet(name))
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def version() -> None:
|
||||||
|
"""Print the installed version."""
|
||||||
|
typer.echo(__version__)
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""Business logic for {{ cookiecutter.project_name }}.
|
||||||
|
|
||||||
|
The CLI layer (``cli.py``) is thin — all logic lives here so it can be
|
||||||
|
unit-tested without invoking the Typer runner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def greet(name: str) -> str:
|
||||||
|
"""Return a greeting string for the given name."""
|
||||||
|
if not name:
|
||||||
|
return "Hello, stranger!"
|
||||||
|
return f"Hello, {name}!"
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Tests package."""
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""Pytest configuration for the CLI template."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }} import cli
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner() -> CliRunner:
|
||||||
|
"""Typer CliRunner — invokes the app in-process."""
|
||||||
|
return CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
"""The Typer app instance."""
|
||||||
|
return cli.app
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""CLI tests — invoke the Typer app via CliRunner."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.cli import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_hello_default(runner: CliRunner) -> None:
|
||||||
|
"""``hello`` with no arg greets the world."""
|
||||||
|
result = runner.invoke(app, ["hello"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Hello, world!" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_hello_name(runner: CliRunner) -> None:
|
||||||
|
"""``hello <name>`` greets the given name."""
|
||||||
|
result = runner.invoke(app, ["hello", "Alice"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Hello, Alice!" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_version(runner: CliRunner) -> None:
|
||||||
|
"""``version`` prints the package version."""
|
||||||
|
result = runner.invoke(app, ["version"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "0.1.0" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_args_shows_help(runner: CliRunner) -> None:
|
||||||
|
"""No args → help (no_args_is_help=True)."""
|
||||||
|
result = runner.invoke(app, [])
|
||||||
|
assert result.exit_code != 0 or "Usage" in result.stdout
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Unit tests for core business logic (no Typer)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.core import __version__, greet
|
||||||
|
|
||||||
|
|
||||||
|
def test_greet_name() -> None:
|
||||||
|
"""greet returns a personalized greeting."""
|
||||||
|
assert greet("Alice") == "Hello, Alice!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_greet_empty() -> None:
|
||||||
|
"""greet with empty name falls back to stranger."""
|
||||||
|
assert greet("") == "Hello, stranger!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_is_string() -> None:
|
||||||
|
"""__version__ is a non-empty string."""
|
||||||
|
assert isinstance(__version__, str)
|
||||||
|
assert __version__
|
||||||
8
.opencode/templates/fullstack/cookiecutter.json
Normal file
8
.opencode/templates/fullstack/cookiecutter.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"project_name": "my-fullstack",
|
||||||
|
"project_type": "fullstack",
|
||||||
|
"description": "Fullstack project description",
|
||||||
|
"use_auth": ["no", "yes"],
|
||||||
|
"use_db": ["yes", "no"],
|
||||||
|
"python_version": "3.13"
|
||||||
|
}
|
||||||
56
.opencode/templates/fullstack/hooks/post_gen_project.py
Normal file
56
.opencode/templates/fullstack/hooks/post_gen_project.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Post-generation hook for the fullstack cookiecutter template.
|
||||||
|
|
||||||
|
Removes files conditional on ``use_auth`` / ``use_db`` from the backend
|
||||||
|
sub-tree (the frontend has no such flags).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_DIR = Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def _remove(path: str) -> None:
|
||||||
|
"""Remove a file or directory relative to the generated project root."""
|
||||||
|
p = PROJECT_DIR / path
|
||||||
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
|
elif p.exists():
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
use_auth = "{{ cookiecutter.use_auth }}"
|
||||||
|
use_db = "{{ cookiecutter.use_db }}"
|
||||||
|
pkg = "{{ cookiecutter.project_name }}"
|
||||||
|
|
||||||
|
if use_auth == "no":
|
||||||
|
_remove(f"backend/src/{pkg}/api/v1/routes/auth.py")
|
||||||
|
_remove(f"backend/src/{pkg}/services/auth_service.py")
|
||||||
|
_remove(f"backend/tests/test_auth.py")
|
||||||
|
|
||||||
|
if use_db == "no":
|
||||||
|
# db is the root cause for the broken-conditional findings: files
|
||||||
|
# with unconditional ``from ...db.models.user import User`` must be
|
||||||
|
# stripped together with db/, otherwise the generated project
|
||||||
|
# fails to import (ImportError/NameError on startup).
|
||||||
|
_remove(f"backend/src/{pkg}/db")
|
||||||
|
_remove("backend/migrations")
|
||||||
|
_remove(f"backend/src/{pkg}/services/user_service.py")
|
||||||
|
_remove(f"backend/src/{pkg}/api/v1/routes/users.py")
|
||||||
|
_remove(f"backend/src/{pkg}/api/v1/dependencies.py")
|
||||||
|
_remove(f"backend/src/{pkg}/schemas/user.py")
|
||||||
|
_remove(f"backend/tests/unit/test_user.py")
|
||||||
|
_remove(f"backend/tests/unit/test_user_service.py")
|
||||||
|
_remove(f"backend/tests/api/test_users.py")
|
||||||
|
if use_auth == "yes":
|
||||||
|
# auth_service imports User; strip it and its wiring too.
|
||||||
|
_remove(f"backend/src/{pkg}/api/v1/routes/auth.py")
|
||||||
|
_remove(f"backend/src/{pkg}/services/auth_service.py")
|
||||||
|
_remove(f"backend/tests/test_auth.py")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
14
.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
14
.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: "/backend"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/frontend"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
54
.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
54
.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults: { run: { working-directory: backend } }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run ruff check .
|
||||||
|
- run: uv run ruff format --check .
|
||||||
|
|
||||||
|
backend-typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults: { run: { working-directory: backend } }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run mypy src tests
|
||||||
|
|
||||||
|
backend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults: { run: { working-directory: backend } }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run pytest
|
||||||
|
|
||||||
|
frontend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults: { run: { working-directory: frontend } }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: '20' }
|
||||||
|
- run: npm install
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [backend-lint, backend-typecheck, backend-test, frontend-test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: echo "All checks passed"
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) {% now 'utc', '%Y' %} slaid098
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
# 🚀 {{ cookiecutter.project_name }}
|
||||||
|
|
||||||
|
> Language switcher: **[English](#-english)** | **[Русский](#-русский)**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<!-- tagline-en:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- tagline-en:end -->
|
||||||
|
|
||||||
|
<!-- summary-en:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- summary-en:end -->
|
||||||
|
|
||||||
|
## 🇺🇸 English
|
||||||
|
|
||||||
|
<!-- features-en:start -->
|
||||||
|
- FastAPI backend (Tortoise ORM) + SvelteKit frontend (Svelte 5 runes)
|
||||||
|
- pydantic-settings configuration; JS frontend with Biome + Knip + Vitest + Playwright e2e
|
||||||
|
- Ruff + mypy strict + pytest (backend); SvelteKit + Vitest (frontend)
|
||||||
|
- Pre-commit hooks (backend); Biome + Knip (frontend)
|
||||||
|
<!-- features-en:end -->
|
||||||
|
|
||||||
|
### ⚡ Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv sync --extra dev && uv run uvicorn main:app --reload
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Support and contacts / Поддержка и контакты
|
||||||
|
|
||||||
|
👉 **[slaid098.dev/support](https://slaid098.dev/support)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🇷🇺 Русский
|
||||||
|
|
||||||
|
<!-- tagline-ru:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- tagline-ru:end -->
|
||||||
|
|
||||||
|
<!-- summary-ru:start -->
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
<!-- summary-ru:end -->
|
||||||
|
|
||||||
|
<!-- features-ru:start -->
|
||||||
|
- Бэкенд FastAPI (Tortoise ORM) + фронтенд SvelteKit (Svelte 5 runes)
|
||||||
|
- Конфигурация через pydantic-settings; JS-фронтенд с Biome + Knip + Vitest + Playwright e2e
|
||||||
|
- Ruff + mypy strict + pytest (бэкенд); SvelteKit + Vitest (фронтенд)
|
||||||
|
- Pre-commit хуки (бэкенд); Biome + Knip (фронтенд)
|
||||||
|
<!-- features-ru:end -->
|
||||||
|
|
||||||
|
### ⚡ Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv sync --extra dev && uv run uvicorn main:app --reload
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Support and contacts / Поддержка и контакты
|
||||||
|
|
||||||
|
👉 **[slaid098.dev/support](https://slaid098.dev/support)**
|
||||||
10
.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/dependabot.yml
vendored
Normal file
10
.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
42
.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/workflows/ci.yml
vendored
Normal file
42
.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run ruff check .
|
||||||
|
- run: uv run ruff format --check .
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run mypy src tests
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run pytest
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv build
|
||||||
18
.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.gitignore
vendored
Normal file
18
.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.5.0
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.10.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
additional_dependencies: [pydantic-settings]
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.6.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-added-large-files
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{{ cookiecutter.python_version }}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) {% now 'utc', '%Y' %} slaid098
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Application
|
||||||
|
APP__ENVIRONMENT=dev
|
||||||
|
SERVER__HOST=0.0.0.0
|
||||||
|
SERVER__PORT=8000
|
||||||
|
|
||||||
|
# Database (nested via __ separator — pydantic-settings convention)
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
DATABASE__URL=sqlite://db.sqlite3
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
# Auth
|
||||||
|
JWT__SECRET=change-me-in-production
|
||||||
|
JWT__ALGORITHM=HS256
|
||||||
|
JWT__EXPIRE_MINUTES=60
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
# IP whitelist (no hardcoded production IPs — override in production)
|
||||||
|
IP_WHITELIST=["127.0.0.1", "::1"]
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""FastAPI application entry point for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.logger import setup_logging
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.db.connection import close_db, init_db
|
||||||
|
{% endif %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.router import api_router
|
||||||
|
|
||||||
|
_BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
{% if cookiecutter.use_db == "yes" %}setup_logging()
|
||||||
|
await init_db()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await close_db(){% else %}setup_logging()
|
||||||
|
yield{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
static_dir = _BASE_DIR / "static"
|
||||||
|
static_dir.mkdir(exist_ok=True)
|
||||||
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Tortoise migrations directory
|
||||||
|
|
||||||
|
This directory holds migration files generated by the built-in Tortoise
|
||||||
|
migrator. Run:
|
||||||
|
|
||||||
|
python -m tortoise.migrator makemigrations
|
||||||
|
|
||||||
|
Generated files land here and are committed to git.
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "{{ cookiecutter.project_name }}"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "{{ cookiecutter.description }}"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
requires-python = ">={{ cookiecutter.python_version }}"
|
||||||
|
authors = [{ name = "slaid098" }]
|
||||||
|
keywords = []
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastapi",
|
||||||
|
"uvicorn[standard]",
|
||||||
|
"pydantic-settings",
|
||||||
|
"loguru",
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
"tortoise-orm",
|
||||||
|
"asyncpg",
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
"passlib[bcrypt]",
|
||||||
|
"pyjwt",
|
||||||
|
{% endif %}
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=5.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"pytest-timeout>=2.2",
|
||||||
|
"httpx",
|
||||||
|
"mypy>=1.10",
|
||||||
|
"ruff>=0.5",
|
||||||
|
"pre-commit>=3.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
|
Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
|
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
|
|
||||||
|
# ── Ruff ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py313"
|
||||||
|
line-length = 100
|
||||||
|
src = ["src", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", "W",
|
||||||
|
"F",
|
||||||
|
"I",
|
||||||
|
"B",
|
||||||
|
"UP",
|
||||||
|
"SIM",
|
||||||
|
"C90",
|
||||||
|
"PL",
|
||||||
|
"RUF",
|
||||||
|
"S",
|
||||||
|
"TRY",
|
||||||
|
"LOG",
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"S101",
|
||||||
|
"S311",
|
||||||
|
"RUF001",
|
||||||
|
"RUF002",
|
||||||
|
"RUF003",
|
||||||
|
"TRY003",
|
||||||
|
"PLR2004",
|
||||||
|
"S106",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.mccabe]
|
||||||
|
max-complexity = 10
|
||||||
|
|
||||||
|
[tool.ruff.lint.pylint]
|
||||||
|
max-args = 5
|
||||||
|
max-branches = 12
|
||||||
|
max-returns = 5
|
||||||
|
max-statements = 50
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607", "PLR0913"]
|
||||||
|
|
||||||
|
# ── mypy ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "{{ cookiecutter.python_version }}"
|
||||||
|
strict = true
|
||||||
|
explicit_package_bases = true
|
||||||
|
warn_return_any = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
|
||||||
|
# ── pytest ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
addopts = "--cov=src --cov-report=term-missing --timeout=120"
|
||||||
|
|
||||||
|
# ── coverage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── project-status ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.project-status]
|
||||||
|
route_line_limit = 50
|
||||||
|
min_test_count = 1
|
||||||
|
require_branch_protection = false
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""{{ cookiecutter.project_name }} package."""
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""API package for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.router import api_router # noqa: F401
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""API router aggregation for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.router import v1_router
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(v1_router, prefix="/v1")
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""v1 API package."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.router import v1_router # noqa: F401
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Shared dependencies for v1 routes.
|
||||||
|
|
||||||
|
``check_ip_whitelist`` is always present; ``get_current_user`` is wired
|
||||||
|
only when ``use_auth == "yes``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings
|
||||||
|
|
||||||
|
DEFAULT_WHITELIST = ["127.0.0.1", "::1"]
|
||||||
|
|
||||||
|
|
||||||
|
async def check_ip_whitelist(request: Request) -> None:
|
||||||
|
"""Reject requests from non-whitelisted IPs.
|
||||||
|
|
||||||
|
Defaults to ``["127.0.0.1", "::1"]`` (no hardcoded production IPs);
|
||||||
|
override via ``IP_WHITELIST`` in env.
|
||||||
|
"""
|
||||||
|
client = request.client.host if request.client else None
|
||||||
|
whitelist = settings.ip_whitelist or DEFAULT_WHITELIST
|
||||||
|
if client and client not in whitelist:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"IP {client} not allowed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
|
||||||
|
|
||||||
|
_bearer = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
|
||||||
|
auth: AuthService = Depends(AuthService),
|
||||||
|
) -> str:
|
||||||
|
"""Resolve the current user from the bearer token (JWT).
|
||||||
|
|
||||||
|
Returns the username/subject of the token. Only present when
|
||||||
|
``use_auth == "yes"``.
|
||||||
|
"""
|
||||||
|
return await auth.get_current_user(credentials.credentials)
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Versioned router (v1) for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.dependencies import check_ip_whitelist
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import users
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import auth
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
v1_router = APIRouter(dependencies=[Depends(check_ip_whitelist)])
|
||||||
|
v1_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||||
|
{% else %}
|
||||||
|
v1_router = APIRouter()
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
v1_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""Routes package for v1."""
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import users # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import auth # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Auth routes — /login, /register (only when use_auth=yes)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.user import Token, UserCreate, UserLogin, UserResponse
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse, status_code=201)
|
||||||
|
async def register(payload: UserCreate) -> UserResponse:
|
||||||
|
"""Register a new user — returns the public profile."""
|
||||||
|
user = await AuthService.register(payload.username, payload.email, payload.password)
|
||||||
|
return UserResponse(id=user.id, username=user.username, email=user.email)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(payload: UserLogin) -> Token:
|
||||||
|
"""Login with username + password — returns a JWT."""
|
||||||
|
access_token = await AuthService.login(payload.username, payload.password)
|
||||||
|
return Token(access_token=access_token)
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""User routes — thin handlers (≤50 lines), delegate to services."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.user import UserResponse
|
||||||
|
from {{ cookiecutter.project_name }}.services.user_service import UserService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserResponse])
|
||||||
|
async def list_users() -> list[UserResponse]:
|
||||||
|
"""List users — thin handler, business logic lives in the service."""
|
||||||
|
users = await UserService.get_users()
|
||||||
|
return [UserResponse.model_validate(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
|
async def get_user(user_id: int) -> UserResponse:
|
||||||
|
"""Get a single user by id — thin handler."""
|
||||||
|
user = await UserService.get_user(user_id)
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Configuration package — settings + logger."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings # noqa: F401
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""Loguru logging setup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Configure loguru sink — remove default handler, add stdout."""
|
||||||
|
logger.remove()
|
||||||
|
logger.add(
|
||||||
|
sys.stdout,
|
||||||
|
level="INFO",
|
||||||
|
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level}</level> | {message}",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Application settings via pydantic-settings.
|
||||||
|
|
||||||
|
Nested fields use the ``__`` separator (pydantic-settings convention):
|
||||||
|
``DATABASE__URL`` -> ``settings.database.url``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Environment(StrEnum):
|
||||||
|
DEV = "dev"
|
||||||
|
STAGING = "staging"
|
||||||
|
PROD = "prod"
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSettings(BaseSettings):
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
class DatabaseSettings(BaseSettings):
|
||||||
|
url: str = "sqlite://db.sqlite3"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
class JWTSettings(BaseSettings):
|
||||||
|
secret: str = "change-me-in-production"
|
||||||
|
algorithm: str = "HS256"
|
||||||
|
expire_minutes: int = 60
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
environment: Environment = Environment.DEV
|
||||||
|
server: ServerSettings = Field(default_factory=ServerSettings)
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
jwt: JWTSettings = Field(default_factory=JWTSettings)
|
||||||
|
{% endif %}
|
||||||
|
ip_whitelist: list[str] = Field(default_factory=lambda: ["127.0.0.1", "::1"])
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% if cookiecutter.use_db == "yes" %}"""DB package — Tortoise ORM connection + models."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.db.connection import close_db, init_db # noqa: F401
|
||||||
|
{% else %}"""DB package (disabled — use_db=no)."""
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Tortoise ORM connection setup.
|
||||||
|
|
||||||
|
Uses ``Path(__file__)``-relative paths (no CWD reliance) so the app is
|
||||||
|
portable regardless of the working directory it is launched from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tortoise import Tortoise
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings
|
||||||
|
|
||||||
|
_MODELS_PATH = "src.{{ cookiecutter.project_name }}.db.models"
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
"""Initialize Tortoise with generate_schemas (built-in, NOT Aerich)."""
|
||||||
|
await Tortoise.init(
|
||||||
|
db_url=settings.database.url,
|
||||||
|
modules={"models": [_MODELS_PATH]},
|
||||||
|
)
|
||||||
|
await Tortoise.generate_schemas(safe=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db() -> None:
|
||||||
|
"""Close all Tortoise connections."""
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Tortoise ORM models."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User # noqa: F401
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""User model.
|
||||||
|
|
||||||
|
When ``use_auth == "yes"`` the ``hashed_password`` field is present;
|
||||||
|
otherwise the model holds only the public profile fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
class User(Model):
|
||||||
|
id = fields.IntField(pk=True)
|
||||||
|
username = fields.CharField(max_length=64, unique=True)
|
||||||
|
email = fields.CharField(max_length=128, unique=True)
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
hashed_password = fields.CharField(max_length=128)
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "users"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.username
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""Pydantic schemas."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.base import MetaResponse, PaginatedResponse # noqa: F401
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.user import UserResponse # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Base schemas — shared response envelopes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class MetaResponse(BaseModel):
|
||||||
|
"""Standard meta block for API responses."""
|
||||||
|
|
||||||
|
total: int
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseModel, Generic[T]):
|
||||||
|
"""Generic paginated envelope: ``{items, meta}``."""
|
||||||
|
|
||||||
|
items: list[T]
|
||||||
|
meta: MetaResponse
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""User schemas.
|
||||||
|
|
||||||
|
When ``use_auth == "yes"`` the auth-related schemas (``UserCreate``,
|
||||||
|
``UserLogin``, ``Token``) are added; ``UserResponse`` and ``UserInput``
|
||||||
|
are always present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserInput(BaseModel):
|
||||||
|
"""Input payload for creating a user."""
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=64)
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""Public user representation (never leaks the password)."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
"""Registration payload — username, email, plain password."""
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=64)
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
"""Login payload — username + plain password."""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
"""JWT response envelope."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
{% endif %}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue