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