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:
Sergey 2026-08-03 17:06:24 +03:00 committed by GitHub
parent 42c8e2d31e
commit 35b6c6e542
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
140 changed files with 3992 additions and 0 deletions

View 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"
}

View 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()

View file

@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly

View 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

View 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/

View file

@ -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

View file

@ -0,0 +1 @@
{{ cookiecutter.python_version }}

View file

@ -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.

View file

@ -0,0 +1,67 @@
# 🚀 {{ cookiecutter.project_name }}
> Language switcher: **[English](#-english)** | **[Русский](#-русский)**
![Cover](assets/cover.png)
<!-- 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)**

View file

@ -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"]

View file

@ -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"}

View file

@ -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.

View file

@ -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

View file

@ -0,0 +1 @@
"""{{ cookiecutter.project_name }} package."""

View file

@ -0,0 +1,3 @@
"""API package for {{ cookiecutter.project_name }}."""
from {{ cookiecutter.project_name }}.api.router import api_router # noqa: F401

View file

@ -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")

View file

@ -0,0 +1,3 @@
"""v1 API package."""
from {{ cookiecutter.project_name }}.api.v1.router import v1_router # noqa: F401

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,3 @@
"""Configuration package — settings + logger."""
from {{ cookiecutter.project_name }}.config.settings import settings # noqa: F401

View file

@ -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}",
)

View file

@ -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()

View file

@ -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 %}

View file

@ -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()

View file

@ -0,0 +1,3 @@
"""Tortoise ORM models."""
from {{ cookiecutter.project_name }}.db.models.user import User # noqa: F401

View file

@ -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

View file

@ -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 %}

View file

@ -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

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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", ""))

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -0,0 +1 @@
"""Tests package."""

View file

@ -0,0 +1 @@
"""API tests package."""

View file

@ -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

View file

@ -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 %}

View file

@ -0,0 +1 @@
"""Integration tests package."""

View file

@ -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

View file

@ -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()

View file

@ -0,0 +1 @@
"""Unit tests package."""

View file

@ -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 %}

View file

@ -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)

View 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"
}

View 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()

View file

@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly

View 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

View 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/

View file

@ -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

View file

@ -0,0 +1 @@
{{ cookiecutter.python_version }}

View file

@ -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.

View file

@ -0,0 +1,65 @@
# 🚀 {{ cookiecutter.project_name }}
> Language switcher: **[English](#-english)** | **[Русский](#-русский)**
![Cover](assets/cover.png)
<!-- 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)**

View file

@ -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

View file

@ -0,0 +1,3 @@
"""{{ cookiecutter.project_name }} package."""
from {{ cookiecutter.project_name }}.core import __version__ # noqa: F401

View file

@ -0,0 +1,6 @@
"""CLI entry point — ``python -m {{ cookiecutter.project_name }}``."""
from {{ cookiecutter.project_name }}.cli import app
if __name__ == "__main__":
app()

View file

@ -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__)

View file

@ -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}!"

View file

@ -0,0 +1 @@
"""Tests package."""

View file

@ -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

View file

@ -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

View file

@ -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__

View 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"
}

View 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()

View 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

View 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"

View file

@ -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.

View file

@ -0,0 +1,67 @@
# 🚀 {{ cookiecutter.project_name }}
> Language switcher: **[English](#-english)** | **[Русский](#-русский)**
![Cover](assets/cover.png)
<!-- 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)**

View file

@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly

View 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

View 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/

View file

@ -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

View file

@ -0,0 +1 @@
{{ cookiecutter.python_version }}

View file

@ -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.

View file

@ -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"]

View file

@ -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"}

View file

@ -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.

View file

@ -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

View file

@ -0,0 +1 @@
"""{{ cookiecutter.project_name }} package."""

View file

@ -0,0 +1,3 @@
"""API package for {{ cookiecutter.project_name }}."""
from {{ cookiecutter.project_name }}.api.router import api_router # noqa: F401

View file

@ -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")

View file

@ -0,0 +1,3 @@
"""v1 API package."""
from {{ cookiecutter.project_name }}.api.v1.router import v1_router # noqa: F401

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,3 @@
"""Configuration package — settings + logger."""
from {{ cookiecutter.project_name }}.config.settings import settings # noqa: F401

View file

@ -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}",
)

View file

@ -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()

View file

@ -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 %}

View file

@ -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()

View file

@ -0,0 +1,3 @@
"""Tortoise ORM models."""
from {{ cookiecutter.project_name }}.db.models.user import User # noqa: F401

View file

@ -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

View file

@ -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 %}

View file

@ -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

View file

@ -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