feat: update project structure and docs

This commit is contained in:
2026-04-25 16:26:33 +03:00
parent 9c3b44b561
commit 9772c3c908
42 changed files with 1342 additions and 6 deletions

BIN
.coverage

Binary file not shown.

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

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# MkDocs output
site/
# Python cache (ignore all)
__pycache__/
*.py[cod]
*$py.class
*.pyc
*.pyo
# opencode skills (agent-only)
.opencode/
AGENTS.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__/

View File

@@ -31,4 +31,22 @@ repos:
language: system language: system
types: [ python ] types: [ python ]
# Update README.md (before commit)
- id: update-readme
name: update-readme
language: system
entry: uv run python scripts/update_readme.py
pass_filenames: false
always_run: true
stages: [ pre-commit ]
# Clean Python cache
- id: clean-pycache
name: clean-pycache
language: system
entry: bash scripts/clean_cache.sh
pass_filenames: false
always_run: true
stages: [ pre-commit ]

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"

146
README.md
View File

@@ -1,5 +1,147 @@
# blog.pyaqa.ru # blog.pyaqa.ru
Блог pyaqa
[![status-badge](https://cicd.pyaqa.ru/api/badges/2/status.svg)](https://cicd.pyaqa.ru/repos/2) [![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: update project structure and docs (2e4a922)
- [QG] Add quality gates on main branch (9c3b44b)
- [Lint] add pipeline (fa8751c)
#### Changed
- pipeline updated (7cd0d8c)
#### Other
- Dev dependencies (0480712)
- [Blog] Preparing (ba45f40)
- blog project init (ef797bc)

View File

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

Binary file not shown.

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,55 @@
"""Common error response schema and exception handlers."""
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):
"""Standard error response format."""
status_code: int
message: str
details: dict | None = None
timestamp: str
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
"""Handle application exceptions with standard response."""
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:
"""Handle HTTP exceptions with standard response."""
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):
"""Register all exception handlers with FastAPI app."""
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."""

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

@@ -0,0 +1,20 @@
"""Application configuration and settings."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings from environment variables."""
app_name: str = "Blog API"
debug: bool = False
host: str = "0.0.0.0"
port: int = 8000
# Database (when added)
database_url: str | None = None
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()

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

@@ -0,0 +1,64 @@
"""Custom application exceptions."""
class AppException(Exception):
"""Base application exception."""
def __init__(self, message: str, status_code: int = 500):
"""Initialize application exception.
Args:
message: Error message.
status_code: HTTP status code.
"""
self.message = message
self.status_code = status_code
super().__init__(self.message)
class NotFoundError(AppException):
"""Resource not found error."""
def __init__(self, message: str = "Resource not found"):
"""Initialize not found error.
Args:
message: Error message.
"""
super().__init__(message, status_code=404)
class ValidationError(AppException):
"""Validation error."""
def __init__(self, message: str = "Validation failed"):
"""Initialize validation error.
Args:
message: Error message.
"""
super().__init__(message, status_code=400)
class UnauthorizedError(AppException):
"""Authentication required."""
def __init__(self, message: str = "Unauthorized"):
"""Initialize unauthorized error.
Args:
message: Error message.
"""
super().__init__(message, status_code=401)
class ForbiddenError(AppException):
"""Permission denied."""
def __init__(self, message: str = "Forbidden"):
"""Initialize forbidden error.
Args:
message: Error message.
"""
super().__init__(message, status_code=403)

View File

@@ -1,21 +1,43 @@
"""FastAPI application factory and entry point."""
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from app.common.error_handler import register_exception_handlers
from app.core.config import settings
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan manager for startup/shutdown events."""
# Startup: initialize DB connections, cache, etc.
yield yield
# Shutdown: cleanup resources
def app_factory(): def app_factory() -> FastAPI:
app = FastAPI(lifespan=lifespan) """Create and configure FastAPI application instance.
Returns:
Configured FastAPI application.
"""
app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)
# Register exception handlers
register_exception_handlers(app)
# Register routers (when added)
# from app.api.v1.router import api_router
# app.include_router(api_router, prefix="/api/v1")
return app return app
def main(): def main():
uvicorn.run(app_factory, factory=True, host="0.0.0.0", port=8000) """Run the application with uvicorn server."""
uvicorn.run(app_factory, factory=True, host=settings.host, port=settings.port)
if __name__ == "__main__": if __name__ == "__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

View File

@@ -7,6 +7,8 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"fastapi>=0.136.0", "fastapi>=0.136.0",
"uvicorn>=0.44.0", "uvicorn>=0.44.0",
"pydantic-settings>=2.7.0",
"dishka>=1.0.0",
] ]
[dependency-groups] [dependency-groups]
@@ -14,6 +16,7 @@ dev = [
{include-group = "lints"}, {include-group = "lints"},
{include-group = "tests"}, {include-group = "tests"},
{include-group = "types"}, {include-group = "types"},
{include-group = "docs"},
"pre-commit>=4.5.1", "pre-commit>=4.5.1",
] ]
tests = [ tests = [
@@ -21,6 +24,7 @@ tests = [
"pytest>=9.0.3", "pytest>=9.0.3",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",
"pytest-cov>=7.1.0", "pytest-cov>=7.1.0",
"mimesis>=13.0.0",
] ]
lints = [ lints = [
"black>=23.7.0", "black>=23.7.0",
@@ -30,12 +34,31 @@ lints = [
types = [ types = [
"mypy>=1.20.1", "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] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"
addopts = "--cov=src --cov-report=term" addopts = "--cov=app --cov-report=term --no-cov-on-fail -p no:cacheprovider"
pythonpath = "." pythonpath = "."
testpaths = "tests" testpaths = "tests"
xfail_strict = true 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
```

30
scripts/clean_cache.sh Executable file
View File

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

70
scripts/commit-msg Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Pre-commit hook: Validate commit message and check for cache files
set -e
# Get commit message file
COMMIT_MSG_FILE="$1"
if [ -z "$COMMIT_MSG_FILE" ]; then
# If called without args, check staged changes for cache files
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
# Validate commit message format
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Pattern: type: lowercase description (max 50 chars, no period)
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
# Check for period at end
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

371
scripts/update_readme.py Normal file
View File

@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""Update README.md with latest project information.
This script:
- Updates Changelog from git commits
- Updates Dependencies from pyproject.toml
- Updates Commands from available scripts
- Updates CI/CD badges
"""
import re
import subprocess
import tomllib
from datetime import datetime
from pathlib import Path
from typing import Any
def get_project_root() -> Path:
"""Get project root directory."""
return Path(__file__).parent.parent
def get_pyproject() -> dict[str, Any]:
"""Parse pyproject.toml."""
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]]:
"""Get latest commits with hash, message, date."""
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:
"""Get last git tag."""
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]:
"""Get list of ignored file patterns from .gitignore."""
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("#"):
# Remove trailing slash for directories
ignored.add(line.rstrip("/"))
return ignored
def commit_has_tracked_changes(commit_hash: str) -> bool:
"""Check if commit has changes to tracked (non-ignored) files."""
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
# Check if file or its parent dir is ignored
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 get_commits_since_tag(tag: str | None) -> list[dict[str, str]]:
"""Get commits since last tag (only commits with tracked file changes)."""
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:
# Skip commits that only change ignored files
if not commit_has_tracked_changes(parts[0]):
continue
commits.append(
{
"hash": parts[0][:7],
"message": parts[1],
"date": parts[2],
"author": parts[3],
}
)
return commits
def categorize_commits(commits: list[dict[str, str]]) -> dict[str, list[str]]:
"""Categorize commits by type (feat, fix, docs, etc.)."""
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:
"""Format commits as GitHub Releases changelog."""
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]]:
"""Extract dependencies from pyproject.toml."""
deps: dict[str, list[str]] = {
"runtime": [],
"tests": [],
"lints": [],
"types": [],
"docs": [],
}
# Runtime dependencies
for dep in pyproject.get("project", {}).get("dependencies", []):
deps["runtime"].append(dep)
# Dev dependency groups
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]]:
"""Get available uv commands from pyproject.toml."""
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:
"""Update Dependencies section in README."""
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:
"""Update Commands section in README."""
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:
"""Update Changelog section in README."""
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:
"""Update README.md with latest information.
Args:
check_only: If True, only check if update needed (for CI)
Returns:
True if changes were made (or needed in check mode)
"""
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
# Get data
pyproject = get_pyproject()
commits = get_commits_since_tag(get_last_tag())
deps = get_dependencies(pyproject)
commands = get_available_commands()
# Generate changelog
version = get_last_tag() or "v0.1.0"
changelog = format_changelog(commits, version)
# Update sections
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
# Write updated content
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():
"""Main entry point."""
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"}

17
tests/conftest.py Normal file
View File

@@ -0,0 +1,17 @@
# Global pytest fixtures and configuration
# Shared across all test types
import sys
import pytest
# Disable Python bytecode cache
sys.dont_write_bytecode = True
@pytest.fixture(scope="session")
def event_loop_policy():
"""Use default event loop policy for asyncio tests."""
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()