Compare commits

...

7 Commits

43 changed files with 1271 additions and 47 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
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"

145
README.md
View File

@@ -1,5 +1,146 @@
# blog.pyaqa.ru
Блог pyaqa
[![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 (9772c3c)
- [QG] Add quality gates on main branch (9c3b44b)
- [Lint] add pipeline (fa8751c)
#### Changed
- pipeline updated (7cd0d8c)
#### Other
- refactor: remove all inline comments from code (c85e981)
- Dev dependencies (0480712)

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

View File

@@ -3,19 +3,25 @@ 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():
app = FastAPI(lifespan=lifespan)
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="0.0.0.0", port=8000)
uvicorn.run(app_factory, factory=True, host=settings.host, port=settings.port)
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 = [
"fastapi>=0.136.0",
"uvicorn>=0.44.0",
"pydantic-settings>=2.7.0",
"dishka>=1.0.0",
]
[dependency-groups]
@@ -14,6 +16,7 @@ dev = [
{include-group = "lints"},
{include-group = "tests"},
{include-group = "types"},
{include-group = "docs"},
"pre-commit>=4.5.1",
]
tests = [
@@ -21,6 +24,7 @@ tests = [
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.1.0",
"mimesis>=13.0.0",
]
lints = [
"black>=23.7.0",
@@ -30,12 +34,31 @@ lints = [
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=src --cov-report=term"
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

View File

@@ -1,41 +0,0 @@
from contextlib import asynccontextmanager
from unittest.mock import patch
import pytest
from fastapi import FastAPI
# Предполагаем, что тестируемый модуль называется `myapp`
# Импортируем из него нужные объекты
from app.main import app_factory, lifespan, main
@pytest.mark.asyncio
async def test_lifespan():
"""Проверяет, что lifespan является корректным асинхронным контекстным менеджером."""
app = FastAPI()
# Проверяем, что lifespan - это asynccontextmanager
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__)
# Проверяем, что контекстный менеджер работает (ничего не ломается)
async with lifespan(app):
pass # Просто убеждаемся, что yield отрабатывает
def test_app_factory():
"""Проверяет, что app_factory создаёт правильное приложение FastAPI с переданным lifespan."""
app = app_factory()
assert isinstance(app, FastAPI)
# Проверяем, что lifespan приложения установлен на функцию lifespan
assert app.router.lifespan_context == lifespan
@patch("app.main.uvicorn.run")
def test_main(mock_uvicorn_run):
"""Проверяет, что main вызывает uvicorn.run с правильными параметрами."""
main()
mock_uvicorn_run.assert_called_once_with(
app_factory,
factory=True,
host="0.0.0.0",
port=8000, # Предполагаемый порт (в коде обрезано, но обычно 8000)
)

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