feat: implement blog project with CI pipeline

This commit is contained in:
2026-04-25 19:19:33 +03:00
parent bd0c21840b
commit 1a5f552b38
42 changed files with 1395 additions and 0 deletions

30
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,30 @@
## Description
<!-- Brief description of changes -->
## Type of Change
<!-- Mark with [x] -->
- [ ] 🚀 Feature (`feat`)
- [ ] 🐛 Bug Fix (`fix`)
- [ ] 📝 Documentation (`docs`)
- [ ] ♻️ Refactor (`refactor`)
- [ ] 🎨 Code Style (`style`)
- [ ] ✅ Tests (`test`)
- [ ] 🔧 Chore (`chore`)
## Checklist
- [ ] Code follows project style guidelines (ruff, isort)
- [ ] Tests added/updated (if applicable)
- [ ] Documentation updated (if applicable)
- [ ] Commit message follows convention (`type: description`)
- [ ] Branch rebased to single commit before merge
- [ ] No cache files in commit (`__pycache__`, `*.pyc`)
## Testing
<!-- Describe how changes were tested -->
## Related Issues
<!-- Link to issues if applicable -->
Fixes #
## Screenshots (if applicable)
<!-- Add screenshots for UI changes -->

52
.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# MkDocs output
site/
# Python cache (ignore all)
__pycache__/
*.py[cod]
*$py.class
*.pyc
*.pyo
# opencode skills (agent-only)
.opencode/
AGENTS.md
.github/PULL_REQUEST_TEMPLATE.md
# Scripts (except hooks)
scripts/*
!scripts/commit-msg
!scripts/post-commit
!scripts/update_readme.py
!scripts/clean_cache.sh
# IDE
.idea/
.vscode/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# mypy
.mypy_cache/
# ruff
.ruff_cache/
# Environment
.env
.env.example
.venv/
venv/
# uv cache
.uv/
# Scripts cache
scripts/__pycache__/

34
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,34 @@
repos:
- repo: local
hooks:
# Run the linter.
- id: ruff
name: ruff
language: system
exclude: __init__.py
types_or: [ python, pyi ]
entry: uv run ruff check .
args: [ --fix ]
# Run the formatter.
- id: ruff-format
name: ruff-format
language: system
entry: uv run ruff format
types_or: [ python, pyi ]
# Отсортировывает импорты в проекте
- id: isort
name: isort
language: system
exclude: __init__.py
entry: uv run isort
args: [ --profile, black, --filter-files ]
- id: mypy
name: mypy
entry: uv run mypy
require_serial: true
language: system
types: [ python ]

18
.woodpecker/deploy.yaml Normal file
View File

@@ -0,0 +1,18 @@
when:
event: [push]
branch: main
steps:
deploy:
image: python:3.13
commands:
- echo "🚀 Deploying to production..."
- echo "Branch: main"
- echo "Commit: $(git rev-parse --short HEAD)"
# Add your deployment commands here
# Example:
# - uv sync --frozen
# - uv run python -m app.main &
- echo "✅ Deployment complete"
when:
status: [success]

View File

@@ -0,0 +1,49 @@
when:
event: [push, pull_request]
steps:
check-branch:
image: alpine/git
commands:
- BRANCH=$(git rev-parse --abbrev-ref HEAD)
- |
echo "Branch: $BRANCH"
if [ "$BRANCH" = "main" ]; then
echo "✓ Production branch (protected)"
elif [ "$BRANCH" = "dev" ]; then
echo "✓ Development branch (protected)"
elif echo "$BRANCH" | grep -qE "^feature/"; then
echo "✓ Feature branch"
elif echo "$BRANCH" | grep -qE "^(fix|hotfix|release)/"; then
echo "✓ Special branch"
else
echo "⚠️ Unusual branch name: $BRANCH"
echo " Recommended: feature/<description>, fix/<description>"
fi
check-commit-message:
image: alpine/git
commands:
- MSG=$(git log -1 --pretty=%s)
- |
echo "Last commit: $MSG"
if echo "$MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore): [a-z]"; then
echo "✓ Commit message follows convention"
else
echo "❌ Invalid commit message format"
echo " Expected: <type>: <description>"
echo " Types: feat, fix, docs, style, refactor, test, chore"
exit 1
fi
check-cache-files:
image: python:3.13
commands:
- |
CACHE_FILES=$(git diff --name-only HEAD~1 | grep -E "__pycache__|\.pyc$" || true)
if [ -n "$CACHE_FILES" ]; then
echo "❌ Cache files in commit:"
echo "$CACHE_FILES"
exit 1
fi
- echo "✓ No cache files in commit"

13
.woodpecker/lints.yaml Normal file
View File

@@ -0,0 +1,13 @@
when:
- event: [push, pull_request]
branch: main
steps:
- name: lint
image: python:3.11
commands:
- pip install uv
- uv sync --no-dev --only-group lints
- uv run black --check .
- uv run ruff check .
- uv run isort --check-only .

12
.woodpecker/tests.yaml Normal file
View File

@@ -0,0 +1,12 @@
when:
- event: [push, pull_request]
branch: main
steps:
- name: tests
image: python:3.11
commands:
- pip install uv
- uv sync --no-dev --group tests
- uv run pytest --cov=app --cov-fail-under=70 --cov-report=term-missing

11
.woodpecker/types.yaml Normal file
View File

@@ -0,0 +1,11 @@
when:
- event: [push, pull_request]
branch: main
steps:
- name: types
image: python:3.11
commands:
- pip install uv
- uv sync --no-dev --only-group types
- uv run mypy .

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 pi3c
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.

137
README.md Normal file
View File

@@ -0,0 +1,137 @@
# blog.pyaqa.ru
[![status-badge](https://cicd.pyaqa.ru/api/badges/2/status.svg)](https://cicd.pyaqa.ru/repos/2)
Блог pyaqa - FastAPI приложение для блога.
## Features
- FastAPI REST API
- Python 3.13+
- Async/await support
- Type hints throughout
- Comprehensive testing
- Auto-generated documentation
## Requirements
- Python 3.13+
- uv package manager
## Installation
```bash
uv sync
```
## Usage
```bash
uv run python -m app.main
```
Server runs on `http://0.0.0.0:8000`
API documentation:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## Available Commands
| Command | Description |
|---------|-------------|
| `uv sync` | Install dependencies |
| `uv run python -m app.main` | Start development server |
| `uv run pytest --cov=app --cov-fail-under=70` | Run tests with coverage |
| `uv run ruff check . --fix` | Run linters |
| `uv run ruff format .` | Format code |
| `uv run isort . --profile black --filter-files` | Sort imports |
| `uv run mypy .` | Type checking |
| `uv run mkdocs build` | Build documentation |
| `uv run mkdocs serve` | Serve documentation locally |
## Dependencies
### Runtime
- dishka>=1.0.0
- fastapi>=0.136.0
- pydantic-settings>=2.7.0
- uvicorn>=0.44.0
### Development
- **Tests**: httpx>=0.28.1, mimesis>=13.0.0, pytest-asyncio>=1.3.0, pytest-cov>=7.1.0, pytest>=9.0.3
- **Lint**: black>=23.7.0, isort>=8.0.1, ruff>=0.15.11
- **Types**: mypy>=1.20.1
- **Docs**: interrogate>=1.7.0, mkdocs>=1.6.0, mkdocstrings[python]>=0.24.0, pydocstyle>=6.3.0
## Project Structure
```
app/
├── main.py
├── core/
│ ├── config.py
│ └── exceptions.py
├── api/
│ └── v1/
├── modules/
└── common/
└── error_handler.py
tests/
├── api/
├── unit/
├── integration/
└── e2e/
docs/
├── api/
└── development/
```
## Architecture
Layered Architecture:
- **API Layer** - HTTP endpoints, request/response handling
- **Service Layer** - Business logic, orchestration
- **Repository Layer** - Data access abstraction
- **Database** - Persistence
Dependency Injection: **Dishka** (not FastAPI Depends)
## Testing
```bash
# Run all tests
uv run pytest --cov=app --cov-fail-under=70
# Run by type
uv run pytest tests/unit/ -v
uv run pytest tests/api/ -v
uv run pytest -m unit -v
uv run pytest -m api -v
```
## Documentation
```bash
# Build docs
uv run mkdocs build
# Serve locally
uv run mkdocs serve
```
## CI/CD
Woodpecker CI is configured in `.woodpecker/` directory.
## License
See [LICENSE](LICENSE) file.
## Changelog
### [v0.1.0] - 2026-04-25
#### Added
- feat: implement blog project with CI pipeline (ed3b31a)

1
app/__init__.py Normal file
View File

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

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API module - HTTP routes and endpoints."""

1
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API version 1 endpoints."""

1
app/common/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Common utilities and shared components."""

View File

@@ -0,0 +1,48 @@
from datetime import datetime
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.exceptions import HTTPException
from app.core.exceptions import AppException
class ErrorResponse(BaseModel):
status_code: int
message: str
details: dict | None = None
timestamp: str
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={
"status_code": exc.status_code,
"message": exc.message,
"timestamp": datetime.utcnow().isoformat(),
},
)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={
"status_code": exc.status_code,
"message": str(exc.detail),
"timestamp": datetime.utcnow().isoformat(),
},
)
def register_exception_handlers(app: FastAPI):
app.add_exception_handler(
AppException,
app_exception_handler, # type: ignore[arg-type]
)
app.add_exception_handler(
HTTPException,
http_exception_handler, # type: ignore[arg-type]
)

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Core module - shared functionality and configuration."""

15
app/core/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Blog API"
debug: bool = False
host: str = "0.0.0.0"
port: int = 8000
database_url: str | None = None
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()

25
app/core/exceptions.py Normal file
View File

@@ -0,0 +1,25 @@
class AppException(Exception):
def __init__(self, message: str, status_code: int = 500):
self.message = message
self.status_code = status_code
super().__init__(self.message)
class NotFoundError(AppException):
def __init__(self, message: str = "Resource not found"):
super().__init__(message, status_code=404)
class ValidationError(AppException):
def __init__(self, message: str = "Validation failed"):
super().__init__(message, status_code=400)
class UnauthorizedError(AppException):
def __init__(self, message: str = "Unauthorized"):
super().__init__(message, status_code=401)
class ForbiddenError(AppException):
def __init__(self, message: str = "Forbidden"):
super().__init__(message, status_code=403)

28
app/main.py Normal file
View File

@@ -0,0 +1,28 @@
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from app.common.error_handler import register_exception_handlers
from app.core.config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
def app_factory() -> FastAPI:
app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)
register_exception_handlers(app)
return app
def main():
uvicorn.run(app_factory, factory=True, host=settings.host, port=settings.port)
if __name__ == "__main__":
main()

1
app/modules/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Feature modules - business logic organized by domain."""

17
docs/api/endpoints.md Normal file
View File

@@ -0,0 +1,17 @@
# API Endpoints
## Overview
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/` | Health check |
## Health Check
```http
GET /
```
**Response:** `200 OK`
Returns application status.

13
docs/api/index.md Normal file
View File

@@ -0,0 +1,13 @@
# API Reference
This section contains auto-generated API documentation from source code docstrings.
## Modules
::: app.main
handler: python
options:
members:
- lifespan
- app_factory
- main

View File

@@ -0,0 +1,43 @@
# Code Style
## Linting & Formatting
```bash
# Run all linters
uv run ruff check . --fix
uv run ruff format .
uv run isort . --profile black --filter-files
# Type checking
uv run mypy .
```
## Documentation
```bash
# Check docstring style
uv run pydocstyle app/
# Check documentation coverage
uv run interrogate app/ -v
# Build documentation
uv run mkdocs build
# Serve documentation locally
uv run mkdocs serve
```
## Pre-commit Hooks
This project uses pre-commit hooks to ensure code quality:
- ruff check
- ruff format
- isort
- mypy
Install hooks:
```bash
uv run pre-commit install
```

31
docs/development/setup.md Normal file
View File

@@ -0,0 +1,31 @@
# Setup Guide
## Prerequisites
- Python 3.13+
- uv package manager
## Installation
```bash
# Clone repository
git clone https://github.com/pyaqa/blog.git
cd blog
# Install dependencies
uv sync
# Run tests
uv run pytest
# Start development server
uv run python -m app.main
```
## Development Server
The server runs on `http://0.0.0.0:8000` by default.
Access interactive API docs at:
- Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`

28
docs/index.md Normal file
View File

@@ -0,0 +1,28 @@
# Blog API
Welcome to the Blog API documentation.
## Features
- FastAPI-based REST API
- Python 3.13+
- Async support
- Type hints throughout
## Quick Start
```bash
# Install dependencies
uv sync
# Run development server
uv run python -m app.main
```
## API Endpoints
See [API Reference](api/endpoints.md) for detailed endpoint documentation.
## Development
See [Development Guide](development/setup.md) for setup instructions.

50
mkdocs.yml Normal file
View File

@@ -0,0 +1,50 @@
site_name: Blog API Documentation
site_description: FastAPI Blog Application Documentation
site_author: Blog Team
repo_url: https://github.com/pyaqa/blog
theme:
name: mkdocs
palette:
- scheme: default
primary: indigo
accent: indigo
toggle:
icon: material/brightness-7
name: Switch to dark mode
- scheme: slate
primary: indigo
accent: indigo
toggle:
icon: material/brightness-4
name: Switch to light mode
plugins:
- search
- mkdocstrings:
handlers:
python:
options:
docstring_style: google
show_root_heading: true
show_source: true
show_bases: true
markdown_extensions:
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.superfences
- admonition
- pymdownx.details
- tables
nav:
- Home: index.md
- API Reference:
- Overview: api/index.md
- Endpoints: api/endpoints.md
- Development:
- Setup: development/setup.md
- Code Style: development/codestyle.md

64
pyproject.toml Normal file
View File

@@ -0,0 +1,64 @@
[project]
name = "blog"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.136.0",
"uvicorn>=0.44.0",
"pydantic-settings>=2.7.0",
"dishka>=1.0.0",
]
[dependency-groups]
dev = [
{include-group = "lints"},
{include-group = "tests"},
{include-group = "types"},
{include-group = "docs"},
"pre-commit>=4.5.1",
]
tests = [
"httpx>=0.28.1",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.1.0",
"mimesis>=13.0.0",
]
lints = [
"black>=23.7.0",
"ruff>=0.15.11",
"isort>=8.0.1",
]
types = [
"mypy>=1.20.1",
]
docs = [
"pydocstyle>=6.3.0",
"interrogate>=1.7.0",
"mkdocs>=1.6.0",
"mkdocstrings[python]>=0.24.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = "--cov=app --cov-report=term --no-cov-on-fail -p no:cacheprovider"
pythonpath = "."
testpaths = "tests"
xfail_strict = true
markers = [
"api: API endpoint tests",
"unit: Unit tests (isolated, fast)",
"integration: Integration tests (DB, external services)",
"e2e: End-to-end tests (full workflows)",
"slow: Slow running tests (skip in CI by default)",
]
# Disable bytecode generation
env = ["PYTHONDONTWRITEBYTECODE=1"]
[tool.uv]
# Disable Python bytecode cache during development
# Set PYTHONDONTWRITEBYTECODE=1 in .env or environment

65
scripts/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Development Scripts
## clean_cache.sh
Clean all Python cache files:
```bash
bash scripts/clean_cache.sh
```
Removes:
- `__pycache__/` directories
- `*.pyc`, `*.pyo` files
- `.pytest_cache/`
- `.mypy_cache/`
- `.ruff_cache/`
- `.coverage`
- `htmlcov/`
## update_readme.py
Update README.md with latest project information:
```bash
uv run python scripts/update_readme.py
```
Check if update needed (for CI):
```bash
uv run python scripts/update_readme.py --check
```
## post-commit
Git hook for auto-updating README after commits.
Install:
```bash
cp scripts/post-commit .git/hooks/post-commit
chmod +x .git/hooks/post-commit
```
## Disable Python Cache During Development
Set environment variables before running Python:
```bash
# Option 1: Export variables
export PYTHONDONTWRITEBYTECODE=1
export UV_NO_CACHE=1
# Option 2: Use with command
PYTHONDONTWRITEBYTECODE=1 uv run python -m app.main
# Option 3: Add to .env (not committed)
echo "PYTHONDONTWRITEBYTECODE=1" >> .env
```
Or use the clean script periodically:
```bash
bash scripts/clean_cache.sh
```

22
scripts/clean_cache.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
echo "Cleaning Python cache files..."
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete 2>/dev/null || true
find . -type f -name "*.pyo" -delete 2>/dev/null || true
rm -rf .pytest_cache/ 2>/dev/null || true
rm -rf .mypy_cache/ 2>/dev/null || true
rm -rf .ruff_cache/ 2>/dev/null || true
rm -f .coverage 2>/dev/null || true
rm -rf htmlcov/ 2>/dev/null || true
echo "✓ Cache cleaned"

64
scripts/commit-msg Executable file
View File

@@ -0,0 +1,64 @@
#!/bin/bash
set -e
COMMIT_MSG_FILE="$1"
if [ -z "$COMMIT_MSG_FILE" ]; then
echo "Checking for cache files in staged changes..."
CACHE_FILES=$(git diff --cached --name-only | grep -E "__pycache__|\.pyc$|\.pyo$" || true)
if [ -n "$CACHE_FILES" ]; then
echo "❌ Attempting to commit Python cache files!"
echo ""
echo "Files:"
echo "$CACHE_FILES"
echo ""
echo "Run: bash scripts/clean_cache.sh"
echo "Or: git reset HEAD <files>"
exit 1
fi
echo "✓ No cache files in staged changes"
exit 0
fi
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
if ! echo "$COMMIT_MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore): [a-z].{0,49}$"; then
echo "❌ Invalid commit message format!"
echo ""
echo "Current message: $COMMIT_MSG"
echo ""
echo "Expected format: <type>: <short description>"
echo ""
echo "Types:"
echo " feat - New feature"
echo " fix - Bug fix"
echo " docs - Documentation"
echo " style - Code style"
echo " refactor - Refactoring"
echo " test - Tests"
echo " chore - Maintenance"
echo ""
echo "Rules:"
echo " - Max 50 characters"
echo " - Lowercase after type"
echo " - Imperative mood (add, not added)"
echo " - No period at end"
echo ""
echo "Good examples:"
echo " feat: add user authentication"
echo " fix: resolve database timeout"
echo " docs: update API docs"
echo ""
exit 1
fi
if echo "$COMMIT_MSG" | grep -qE "\.$"; then
echo "❌ Commit message should not end with a period"
exit 1
fi
echo "✓ Commit message valid: $COMMIT_MSG"
exit 0

18
scripts/post-commit Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Post-commit hook: Update README.md automatically
set -e
echo "Updating README.md..."
# Run README update script
uv run python scripts/update_readme.py
# Check if README changed
if ! git diff --quiet README.md; then
echo "✓ README.md was updated"
echo " Review changes and commit if needed:"
echo " git add README.md && git commit -m 'docs: update README [skip ci]'"
else
echo "✓ README.md is up to date"
fi

358
scripts/update_readme.py Normal file
View File

@@ -0,0 +1,358 @@
#!/usr/bin/env python3
import re
import subprocess
import tomllib
from datetime import datetime
from pathlib import Path
from typing import Any
def get_project_root() -> Path:
return Path(__file__).parent.parent
def get_pyproject() -> dict[str, Any]:
root = get_project_root()
with open(root / "pyproject.toml", "rb") as f:
return tomllib.load(f)
def get_latest_commits(count: int = 10) -> list[dict[str, str]]:
result = subprocess.run(
["git", "log", "--format=%H|%s|%ad|%an", "--date=short", f"-n{count}"],
capture_output=True,
text=True,
cwd=get_project_root(),
)
commits = []
for line in result.stdout.strip().split("\n"):
if line:
parts = line.split("|")
if len(parts) >= 4:
commits.append(
{
"hash": parts[0][:7],
"message": parts[1],
"date": parts[2],
"author": parts[3],
}
)
return commits
def get_last_tag() -> str | None:
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
cwd=get_project_root(),
)
return result.stdout.strip() if result.returncode == 0 else None
def get_ignored_files() -> set[str]:
gitignore_path = get_project_root() / ".gitignore"
ignored = set()
if gitignore_path.exists():
for line in gitignore_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#"):
ignored.add(line.rstrip("/"))
return ignored
def commit_has_tracked_changes(commit_hash: str) -> bool:
result = subprocess.run(
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash],
capture_output=True,
text=True,
cwd=get_project_root(),
)
if not result.stdout.strip():
return False
ignored = get_ignored_files()
for file_path in result.stdout.strip().split("\n"):
if not file_path:
continue
parts = file_path.split("/")
is_ignored = False
for i in range(len(parts)):
path_part = "/".join(parts[: i + 1])
for pattern in ignored:
if pattern.endswith("*"):
if path_part.startswith(pattern[:-1]):
is_ignored = True
break
elif path_part == pattern or parts[-1] == pattern:
is_ignored = True
break
if is_ignored:
break
if not is_ignored:
return True
return False
def commit_has_skip_ci_message(commit_hash: str) -> bool:
result = subprocess.run(
["git", "log", "-1", "--format=%s", commit_hash],
capture_output=True,
text=True,
cwd=get_project_root(),
)
msg = result.stdout.strip().lower()
return "[skip ci]" in msg or "[skip-ci]" in msg or "[ci skip]" in msg
def commit_only_changes_readme(commit_hash: str) -> bool:
result = subprocess.run(
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash],
capture_output=True,
text=True,
cwd=get_project_root(),
)
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
return files == ["README.md"]
def get_commits_since_tag(tag: str | None) -> list[dict[str, str]]:
if tag:
result = subprocess.run(
["git", "log", "--format=%H|%s|%ad|%an", "--date=short", f"{tag}..HEAD"],
capture_output=True,
text=True,
cwd=get_project_root(),
)
else:
result = subprocess.run(
["git", "log", "--format=%H|%s|%ad|%an", "--date=short", "-n10"],
capture_output=True,
text=True,
cwd=get_project_root(),
)
commits = []
for line in result.stdout.strip().split("\n"):
if line:
parts = line.split("|")
if len(parts) >= 4:
commit_hash = parts[0]
if commit_has_skip_ci_message(commit_hash):
continue
if commit_only_changes_readme(commit_hash):
continue
if not commit_has_tracked_changes(commit_hash):
continue
commits.append(
{
"hash": commit_hash[:7],
"message": parts[1],
"date": parts[2],
"author": parts[3],
}
)
return commits
def categorize_commits(commits: list[dict[str, str]]) -> dict[str, list[str]]:
categories: dict[str, list[str]] = {
"Added": [],
"Changed": [],
"Fixed": [],
"Removed": [],
"Other": [],
}
for commit in commits:
msg = commit["message"].lower()
entry = f"- {commit['message']} ({commit['hash']})"
if msg.startswith("feat") or "add" in msg:
categories["Added"].append(entry)
elif msg.startswith("fix") or "fix" in msg:
categories["Fixed"].append(entry)
elif msg.startswith("change") or "update" in msg:
categories["Changed"].append(entry)
elif msg.startswith("remove") or "delete" in msg:
categories["Removed"].append(entry)
else:
categories["Other"].append(entry)
return categories
def format_changelog(commits: list[dict[str, str]], version: str = "v0.1.0") -> str:
categorized = categorize_commits(commits)
today = datetime.now().strftime("%Y-%m-%d")
lines = [f"### [{version}] - {today}"]
for section, entries in categorized.items():
if entries:
lines.append(f"\n#### {section}")
lines.extend(entries)
return "\n".join(lines)
def get_dependencies(pyproject: dict[str, Any]) -> dict[str, list[str]]:
deps: dict[str, list[str]] = {
"runtime": [],
"tests": [],
"lints": [],
"types": [],
"docs": [],
}
for dep in pyproject.get("project", {}).get("dependencies", []):
deps["runtime"].append(dep)
dep_groups = pyproject.get("dependency-groups", {})
if "tests" in dep_groups:
for dep in dep_groups["tests"]:
if isinstance(dep, str):
deps["tests"].append(dep)
if "lints" in dep_groups:
for dep in dep_groups["lints"]:
if isinstance(dep, str):
deps["lints"].append(dep)
if "types" in dep_groups:
for dep in dep_groups["types"]:
if isinstance(dep, str):
deps["types"].append(dep)
if "docs" in dep_groups:
for dep in dep_groups["docs"]:
if isinstance(dep, str):
deps["docs"].append(dep)
return deps
def get_available_commands() -> list[dict[str, str]]:
commands = [
{"cmd": "uv sync", "desc": "Install dependencies"},
{"cmd": "uv run python -m app.main", "desc": "Start development server"},
{
"cmd": "uv run pytest --cov=app --cov-fail-under=70",
"desc": "Run tests with coverage",
},
{"cmd": "uv run ruff check . --fix", "desc": "Run linters"},
{"cmd": "uv run ruff format .", "desc": "Format code"},
{
"cmd": "uv run isort . --profile black --filter-files",
"desc": "Sort imports",
},
{"cmd": "uv run mypy .", "desc": "Type checking"},
{"cmd": "uv run mkdocs build", "desc": "Build documentation"},
{"cmd": "uv run mkdocs serve", "desc": "Serve documentation locally"},
]
return commands
def update_dependencies_section(content: str, deps: dict[str, list[str]]) -> str:
section_pattern = r"(## Dependencies\n.*?)(\n## |\Z)"
deps_text = "## Dependencies\n\n"
if deps["runtime"]:
deps_text += "### Runtime\n"
for dep in sorted(deps["runtime"]):
deps_text += f"- {dep}\n"
deps_text += "\n"
if deps["tests"]:
deps_text += "### Development\n"
deps_text += "- **Tests**: " + ", ".join(sorted(deps["tests"])) + "\n"
if deps["lints"]:
deps_text += "- **Lint**: " + ", ".join(sorted(deps["lints"])) + "\n"
if deps["types"]:
deps_text += "- **Types**: " + ", ".join(sorted(deps["types"])) + "\n"
if deps["docs"]:
deps_text += "- **Docs**: " + ", ".join(sorted(deps["docs"])) + "\n"
deps_text += "\n"
replacement = f"{deps_text}\\2"
return re.sub(section_pattern, replacement, content, flags=re.DOTALL)
def update_commands_section(content: str, commands: list[dict[str, str]]) -> str:
section_pattern = r"(## Available Commands\n.*?\|.*?\n\|---\|.*?\n)(.*?)(\n## |\Z)"
commands_table = "| Command | Description |\n|---------|-------------|\n"
for cmd in commands:
commands_table += f"| `{cmd['cmd']}` | {cmd['desc']} |\n"
commands_table += "\n"
replacement = f"\\1{commands_table}\\3"
return re.sub(section_pattern, replacement, content, flags=re.DOTALL)
def update_changelog_section(content: str, changelog: str) -> str:
section_pattern = r"(## Changelog\n)(.*?)(\Z)"
replacement = f"\\1\n{changelog}\n\\3"
return re.sub(section_pattern, replacement, content, flags=re.DOTALL)
def update_readme(check_only: bool = False) -> bool:
readme_path = get_project_root() / "README.md"
if not readme_path.exists():
print("README.md not found")
return False
content = readme_path.read_text()
original_content = content
pyproject = get_pyproject()
commits = get_commits_since_tag(get_last_tag())
deps = get_dependencies(pyproject)
commands = get_available_commands()
version = get_last_tag() or "v0.1.0"
changelog = format_changelog(commits, version)
content = update_changelog_section(content, changelog)
content = update_dependencies_section(content, deps)
content = update_commands_section(content, commands)
if check_only:
needs_update = content != original_content
if needs_update:
print("README.md needs update")
else:
print("README.md is up to date")
return needs_update
if content != original_content:
readme_path.write_text(content)
print("README.md updated successfully")
return True
else:
print("No changes needed")
return False
def main():
import sys
check_only = "--check" in sys.argv
updated = update_readme(check_only=check_only)
if check_only and updated:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()

0
tests/api/__init__.py Normal file
View File

22
tests/api/conftest.py Normal file
View File

@@ -0,0 +1,22 @@
# API test fixtures
# Provides: httpx.AsyncClient, authentication helpers, test API data
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.fixture
async def client():
"""Create async HTTP client for API testing."""
from app.main import app_factory
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
def auth_headers():
"""Return mock authentication headers."""
return {"Authorization": "Bearer test_token"}

8
tests/conftest.py Normal file
View File

@@ -0,0 +1,8 @@
import pytest
@pytest.fixture(scope="session")
def event_loop_policy():
import asyncio
return asyncio.DefaultEventLoopPolicy()

0
tests/e2e/__init__.py Normal file
View File

27
tests/e2e/conftest.py Normal file
View File

@@ -0,0 +1,27 @@
# E2E test fixtures
# Provides: full application state, end-to-end workflows, cleanup
import pytest
@pytest.fixture
async def e2e_app():
"""Create full application instance for E2E testing."""
from app.main import app_factory
app = app_factory()
yield app
# Cleanup after E2E test
@pytest.fixture
def e2e_user_data():
"""Generate realistic user data for E2E scenarios."""
from mimesis import Person
person = Person()
return {
"username": person.username(),
"email": person.email(),
"password": "SecurePass123!",
}

View File

View File

@@ -0,0 +1,18 @@
# Integration test fixtures
# Provides: test database, external service connections
import pytest
@pytest.fixture
def test_db_connection():
"""Create test database connection."""
# TODO: Implement when DB is added to project
yield "test_db"
@pytest.fixture
def cleanup_db():
"""Cleanup database after test."""
yield
# TODO: Implement cleanup logic

0
tests/unit/__init__.py Normal file
View File

18
tests/unit/conftest.py Normal file
View File

@@ -0,0 +1,18 @@
# Unit test fixtures
# Provides: mocks, stubs, isolated test data
from unittest.mock import AsyncMock, Mock
import pytest
@pytest.fixture
def mock_service():
"""Create a mock service for unit testing."""
return Mock()
@pytest.fixture
def mock_async_service():
"""Create an async mock service for unit testing."""
return AsyncMock()

View File

@@ -0,0 +1,33 @@
from contextlib import asynccontextmanager
from unittest.mock import patch
import pytest
from fastapi import FastAPI
from app.main import app_factory, lifespan, main
@pytest.mark.asyncio
async def test_lifespan():
app = FastAPI()
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__)
async with lifespan(app):
pass
def test_app_factory():
app = app_factory()
assert isinstance(app, FastAPI)
assert app.router.lifespan_context == lifespan
@patch("app.main.uvicorn.run")
def test_main(mock_uvicorn_run):
main()
mock_uvicorn_run.assert_called_once_with(
app_factory,
factory=True,
host="0.0.0.0",
port=8000,
)