diff --git a/.coverage b/.coverage deleted file mode 100644 index 7e68d20..0000000 Binary files a/.coverage and /dev/null differ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9919a2f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ +## Description + + +## Type of Change + +- [ ] 🚀 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 + + +## Related Issues + +Fixes # + +## Screenshots (if applicable) + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31dce73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# MkDocs output +site/ + +# Python cache (ignore all) +**/__pycache__/ +*.py[cod] +*$py.class +*.pyc +*.pyo + +# opencode skills (agent-only) +.opencode/ +AGENTS.md +.github/ + +# Scripts (except hooks) +scripts/ + +# 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__/ + diff --git a/.woodpecker/comment_pr.yaml b/.woodpecker/comment_pr.yaml new file mode 100644 index 0000000..f44d57b --- /dev/null +++ b/.woodpecker/comment_pr.yaml @@ -0,0 +1,24 @@ +when: + event: [push, pull_request] + +steps: + - name: comment + image: mcs94/gitea-comment + settings: + gitea_address: https://git.pyaqa.ru + gitea_token: + from_secret: gitea_token + comment: > + ✅ Build ${CI_BUILD_EVENT} of `${CI_REPO_NAME}` has status `${CI_BUILD_STATUS}`. + + 📝 Commit by ${CI_COMMIT_AUTHOR} on `${CI_COMMIT_BRANCH}`: + + `${CI_COMMIT_MESSAGE}` + + 🌐 ${CI_BUILD_LINK} + +depends_on: + - lint + - type + - test + diff --git a/.woodpecker/lints.yaml b/.woodpecker/lint.yaml similarity index 93% rename from .woodpecker/lints.yaml rename to .woodpecker/lint.yaml index 9e48e81..b63f994 100644 --- a/.woodpecker/lints.yaml +++ b/.woodpecker/lint.yaml @@ -1,6 +1,6 @@ when: - event: [push, pull_request] - branch: main + branch: dev steps: - name: lint @@ -11,3 +11,4 @@ steps: - uv run black --check . - uv run ruff check . - uv run isort --check-only . + diff --git a/.woodpecker/tests.yaml b/.woodpecker/test.yaml similarity index 86% rename from .woodpecker/tests.yaml rename to .woodpecker/test.yaml index d1e1421..7936fae 100644 --- a/.woodpecker/tests.yaml +++ b/.woodpecker/test.yaml @@ -1,12 +1,11 @@ when: - event: [push, pull_request] - branch: main + branch: dev steps: - - name: tests + - name: test 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 - diff --git a/.woodpecker/types.yaml b/.woodpecker/type.yaml similarity index 83% rename from .woodpecker/types.yaml rename to .woodpecker/type.yaml index 6e2cebe..fd1ad87 100644 --- a/.woodpecker/types.yaml +++ b/.woodpecker/type.yaml @@ -1,9 +1,9 @@ when: - event: [push, pull_request] - branch: main + branch: dev steps: - - name: types + - name: type image: python:3.11 commands: - pip install uv diff --git a/app/__init__.py b/app/__init__.py index e69de29..18b665e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Application package.""" diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index e6ff294..0000000 Binary files a/app/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc deleted file mode 100644 index 83b9a5f..0000000 Binary files a/app/__pycache__/main.cpython-313.pyc and /dev/null differ diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..d5bdbf2 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API module - HTTP routes and endpoints.""" diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..201dac4 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1 @@ +"""API version 1 endpoints.""" diff --git a/app/common/__init__.py b/app/common/__init__.py new file mode 100644 index 0000000..72e2d07 --- /dev/null +++ b/app/common/__init__.py @@ -0,0 +1 @@ +"""Common utilities and shared components.""" diff --git a/app/common/error_handler.py b/app/common/error_handler.py new file mode 100644 index 0000000..572d526 --- /dev/null +++ b/app/common/error_handler.py @@ -0,0 +1,48 @@ +from datetime import datetime, timezone + +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[str, str] | 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.now(timezone.utc).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.now(timezone.utc).isoformat(), + }, + ) + + +def register_exception_handlers(app: FastAPI) -> None: + app.add_exception_handler( + AppException, + app_exception_handler, # type: ignore[arg-type] + ) + app.add_exception_handler( + HTTPException, + http_exception_handler, # type: ignore[arg-type] + ) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..787c301 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core module - shared functionality and configuration.""" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..98e4037 --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..b8c7d84 --- /dev/null +++ b/app/core/exceptions.py @@ -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) diff --git a/app/main.py b/app/main.py index 70dbf6e..eeeb8c2 100644 --- a/app/main.py +++ b/app/main.py @@ -1,20 +1,21 @@ from contextlib import asynccontextmanager +from typing import AsyncGenerator import uvicorn from fastapi import FastAPI @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: yield -def app_factory(): +def app_factory() -> FastAPI: app = FastAPI(lifespan=lifespan) return app -def main(): +def main() -> None: uvicorn.run(app_factory, factory=True, host="0.0.0.0", port=8000) diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..6ebd16f --- /dev/null +++ b/app/modules/__init__.py @@ -0,0 +1 @@ +"""Feature modules - business logic organized by domain.""" diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md new file mode 100644 index 0000000..3197cc0 --- /dev/null +++ b/docs/api/endpoints.md @@ -0,0 +1,17 @@ +# API Endpoints + +## Overview + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/` | Health check | + +## Health Check + +```http +GET / +``` + +**Response:** `200 OK` + +Returns application status. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..c7c6cfd --- /dev/null +++ b/docs/api/index.md @@ -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 diff --git a/docs/development/codestyle.md b/docs/development/codestyle.md new file mode 100644 index 0000000..bf8bbb5 --- /dev/null +++ b/docs/development/codestyle.md @@ -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 +``` diff --git a/docs/development/setup.md b/docs/development/setup.md new file mode 100644 index 0000000..5b0df7a --- /dev/null +++ b/docs/development/setup.md @@ -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` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..cabebe0 --- /dev/null +++ b/docs/index.md @@ -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. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..76e2b79 --- /dev/null +++ b/mkdocs.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d1b1794..26c61c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,8 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "fastapi>=0.136.0", + "pydantic>=2.13.2", + "pydantic-settings>=2.14.0", "uvicorn>=0.44.0", ] @@ -18,6 +20,7 @@ dev = [ ] tests = [ "httpx>=0.28.1", + "mimesis>=19.1.0", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", "pytest-cov>=7.1.0", @@ -28,6 +31,7 @@ lints = [ "isort>=8.0.1", ] types = [ + "mimesis>=19.1.0", "mypy>=1.20.1", ] @@ -39,3 +43,11 @@ pythonpath = "." testpaths = "tests" xfail_strict = true +[tool.mypy] +strict = true +plugins = ["pydantic.mypy"] + +[tool.isort] +profile = "black" +filter_files = true + diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..6dd3ceb --- /dev/null +++ b/scripts/README.md @@ -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 +``` diff --git a/scripts/clean_cache.sh b/scripts/clean_cache.sh new file mode 100755 index 0000000..ccc4a9d --- /dev/null +++ b/scripts/clean_cache.sh @@ -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" diff --git a/scripts/commit-msg b/scripts/commit-msg new file mode 100755 index 0000000..d087551 --- /dev/null +++ b/scripts/commit-msg @@ -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 " + 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: : " + 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 diff --git a/scripts/post-commit b/scripts/post-commit new file mode 100755 index 0000000..ccb601c --- /dev/null +++ b/scripts/post-commit @@ -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 diff --git a/scripts/update_readme.py b/scripts/update_readme.py new file mode 100644 index 0000000..97e1be3 --- /dev/null +++ b/scripts/update_readme.py @@ -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() -> None: + 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() diff --git a/tests/__pycache__/test_app_run.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_app_run.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index a8580fb..0000000 Binary files a/tests/__pycache__/test_app_run.cpython-313-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000..5301bc6 --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,24 @@ +# API test fixtures +# Provides: httpx.AsyncClient, authentication helpers, test API data + +from typing import AsyncGenerator + +import pytest +from httpx import ASGITransport, AsyncClient + + +@pytest.fixture +async def client() -> AsyncGenerator[AsyncClient, None]: + """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() -> dict[str, str]: + """Return mock authentication headers.""" + return {"Authorization": "Bearer test_token"} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3dd919e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +from asyncio import AbstractEventLoopPolicy, DefaultEventLoopPolicy + +import pytest + + +@pytest.fixture(scope="session") +def event_loop_policy() -> AbstractEventLoopPolicy: + return DefaultEventLoopPolicy() diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..a449049 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,30 @@ +# E2E test fixtures +# Provides: full application state, end-to-end workflows, cleanup + +from typing import AsyncGenerator + +import pytest +from fastapi import FastAPI + + +@pytest.fixture +async def e2e_app() -> AsyncGenerator[FastAPI, None]: + """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() -> dict[str, str]: + """Generate realistic user data for E2E scenarios.""" + from mimesis import Person + + person = Person() + return { + "username": person.username(), + "email": person.email(), + "password": "SecurePass123!", + } diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..eeca67a --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,20 @@ +# Integration test fixtures +# Provides: test database, external service connections + +from typing import Generator + +import pytest + + +@pytest.fixture +def test_db_connection() -> Generator[str, None, None]: + """Create test database connection.""" + # TODO: Implement when DB is added to project + yield "test_db" + + +@pytest.fixture +def cleanup_db() -> Generator[None, None, None]: + """Cleanup database after test.""" + yield + # TODO: Implement cleanup logic diff --git a/tests/test_app_run.py b/tests/test_app_run.py index 386e900..d1f0aac 100644 --- a/tests/test_app_run.py +++ b/tests/test_app_run.py @@ -1,5 +1,5 @@ from contextlib import asynccontextmanager -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from fastapi import FastAPI @@ -10,19 +10,19 @@ from app.main import app_factory, lifespan, main @pytest.mark.asyncio -async def test_lifespan(): +async def test_lifespan() -> None: """Проверяет, что lifespan является корректным асинхронным контекстным менеджером.""" app = FastAPI() # Проверяем, что lifespan - это asynccontextmanager - assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) + assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type] # Проверяем, что контекстный менеджер работает (ничего не ломается) async with lifespan(app): pass # Просто убеждаемся, что yield отрабатывает -def test_app_factory(): - """Проверяет, что app_factory создаёт правильное приложение FastAPI с переданным lifespan.""" +def test_app_factory() -> None: + """Проверяет, что app_factory создаёт приложение FastAPI с переданным lifespan.""" app = app_factory() assert isinstance(app, FastAPI) # Проверяем, что lifespan приложения установлен на функцию lifespan @@ -30,7 +30,7 @@ def test_app_factory(): @patch("app.main.uvicorn.run") -def test_main(mock_uvicorn_run): +def test_main(mock_uvicorn_run: Mock) -> None: """Проверяет, что main вызывает uvicorn.run с правильными параметрами.""" main() mock_uvicorn_run.assert_called_once_with( diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..d9acc02 --- /dev/null +++ b/tests/unit/conftest.py @@ -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() -> Mock: + """Create a mock service for unit testing.""" + return Mock() + + +@pytest.fixture +def mock_async_service() -> AsyncMock: + """Create an async mock service for unit testing.""" + return AsyncMock() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..61ffcb8 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,52 @@ +import os +from unittest.mock import patch + +from app.core.config import Settings + + +class TestSettings: + def test_default_values(self) -> None: + settings = Settings() + assert settings.app_name == "Blog API" + assert settings.debug is False + assert settings.host == "0.0.0.0" + assert settings.port == 8000 + assert settings.database_url is None + + def test_custom_values(self) -> None: + settings = Settings( + app_name="Test API", + debug=True, + host="localhost", + port=9000, + database_url="postgresql://test", + ) + assert settings.app_name == "Test API" + assert settings.debug is True + assert settings.host == "localhost" + assert settings.port == 9000 + assert settings.database_url == "postgresql://test" + + def test_settings_from_env(self) -> None: + with patch.dict( + os.environ, + { + "APP_NAME": "Env API", + "DEBUG": "true", + "HOST": "127.0.0.1", + "PORT": "8080", + "DATABASE_URL": "sqlite:///test.db", + }, + ): + settings = Settings() + assert settings.app_name == "Env API" + assert settings.debug is True + assert settings.host == "127.0.0.1" + assert settings.port == 8080 + assert settings.database_url == "sqlite:///test.db" + + def test_global_settings_instance(self) -> None: + from app.core.config import settings + + assert isinstance(settings, Settings) + assert settings.app_name == "Blog API" diff --git a/tests/unit/test_error_handler.py b/tests/unit/test_error_handler.py new file mode 100644 index 0000000..5b4c621 --- /dev/null +++ b/tests/unit/test_error_handler.py @@ -0,0 +1,110 @@ +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest +from fastapi import FastAPI, Request +from starlette.exceptions import HTTPException + +from app.common.error_handler import ( + ErrorResponse, + app_exception_handler, + http_exception_handler, + register_exception_handlers, +) +from app.core.exceptions import AppException + + +class TestErrorResponse: + def test_error_response_creation(self) -> None: + response = ErrorResponse( + status_code=400, + message="Bad request", + timestamp=datetime.now(timezone.utc).isoformat(), + ) + assert response.status_code == 400 + assert response.message == "Bad request" + assert response.details is None + + def test_error_response_with_details(self) -> None: + response = ErrorResponse( + status_code=500, + message="Internal error", + details={"field": "value"}, + timestamp=datetime.now(timezone.utc).isoformat(), + ) + assert response.status_code == 500 + assert response.message == "Internal error" + assert response.details == {"field": "value"} + + +class TestAppExceptionHandler: + @pytest.mark.asyncio + async def test_app_exception_handler(self) -> None: + request = Mock(spec=Request) + exc = AppException(message="Test error", status_code=400) + + response = await app_exception_handler(request, exc) + + assert response.status_code == 400 + body = bytes(response.body).decode() + assert "Test error" in body + assert "400" in body + + @pytest.mark.asyncio + async def test_app_exception_handler_content(self) -> None: + request = Mock(spec=Request) + exc = AppException(message="Validation error", status_code=422) + + with patch("app.common.error_handler.datetime") as mock_datetime: + mock_datetime.now.return_value.isoformat.return_value = ( + "2024-01-01T00:00:00" + ) + + response = await app_exception_handler(request, exc) + + content = bytes(response.body).decode() + assert "Validation error" in content + assert "422" in content + assert "2024-01-01T00:00:00" in content + + +class TestHttpExceptionHandler: + @pytest.mark.asyncio + async def test_http_exception_handler(self) -> None: + request = Mock(spec=Request) + exc = HTTPException(status_code=404, detail="Not found") + + response = await http_exception_handler(request, exc) + + assert response.status_code == 404 + body = bytes(response.body).decode() + assert "Not found" in body + assert "404" in body + + @pytest.mark.asyncio + async def test_http_exception_handler_content(self) -> None: + request = Mock(spec=Request) + exc = HTTPException(status_code=503, detail="Service unavailable") + + with patch("app.common.error_handler.datetime") as mock_datetime: + mock_datetime.now.return_value.isoformat.return_value = ( + "2024-01-01T12:00:00" + ) + + response = await http_exception_handler(request, exc) + + content = bytes(response.body).decode() + assert "Service unavailable" in content + assert "503" in content + assert "2024-01-01T12:00:00" in content + + +class TestRegisterExceptionHandlers: + def test_register_exception_handlers(self) -> None: + app = Mock(spec=FastAPI) + + register_exception_handlers(app) + + assert app.add_exception_handler.call_count == 2 + app.add_exception_handler.assert_any_call(AppException, app_exception_handler) + app.add_exception_handler.assert_any_call(HTTPException, http_exception_handler) diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..73c928b --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,87 @@ +from app.core.exceptions import ( + AppException, + ForbiddenError, + NotFoundError, + UnauthorizedError, + ValidationError, +) + + +class TestAppException: + def test_default_status_code(self) -> None: + exc = AppException(message="Test error") + assert exc.message == "Test error" + assert exc.status_code == 500 + + def test_custom_status_code(self) -> None: + exc = AppException(message="Custom error", status_code=400) + assert exc.message == "Custom error" + assert exc.status_code == 400 + + def test_string_representation(self) -> None: + exc = AppException(message="Error message") + assert str(exc) == "Error message" + + +class TestNotFoundError: + def test_default_message(self) -> None: + exc = NotFoundError() + assert exc.message == "Resource not found" + assert exc.status_code == 404 + + def test_custom_message(self) -> None: + exc = NotFoundError(message="Item not found") + assert exc.message == "Item not found" + assert exc.status_code == 404 + + def test_is_subclass_of_app_exception(self) -> None: + exc = NotFoundError() + assert isinstance(exc, AppException) + + +class TestValidationError: + def test_default_message(self) -> None: + exc = ValidationError() + assert exc.message == "Validation failed" + assert exc.status_code == 400 + + def test_custom_message(self) -> None: + exc = ValidationError(message="Invalid email format") + assert exc.message == "Invalid email format" + assert exc.status_code == 400 + + def test_is_subclass_of_app_exception(self) -> None: + exc = ValidationError() + assert isinstance(exc, AppException) + + +class TestUnauthorizedError: + def test_default_message(self) -> None: + exc = UnauthorizedError() + assert exc.message == "Unauthorized" + assert exc.status_code == 401 + + def test_custom_message(self) -> None: + exc = UnauthorizedError(message="Invalid credentials") + assert exc.message == "Invalid credentials" + assert exc.status_code == 401 + + def test_is_subclass_of_app_exception(self) -> None: + exc = UnauthorizedError() + assert isinstance(exc, AppException) + + +class TestForbiddenError: + def test_default_message(self) -> None: + exc = ForbiddenError() + assert exc.message == "Forbidden" + assert exc.status_code == 403 + + def test_custom_message(self) -> None: + exc = ForbiddenError(message="Access denied") + assert exc.message == "Access denied" + assert exc.status_code == 403 + + def test_is_subclass_of_app_exception(self) -> None: + exc = ForbiddenError() + assert isinstance(exc, AppException) diff --git a/tests/unit/test_unit_app_run.py b/tests/unit/test_unit_app_run.py new file mode 100644 index 0000000..c4d3c1f --- /dev/null +++ b/tests/unit/test_unit_app_run.py @@ -0,0 +1,33 @@ +from contextlib import asynccontextmanager +from unittest.mock import Mock, patch + +import pytest +from fastapi import FastAPI + +from app.main import app_factory, lifespan, main + + +@pytest.mark.asyncio +async def test_lifespan() -> None: + app = FastAPI() + assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type] + + async with lifespan(app): + pass + + +def test_app_factory() -> None: + app = app_factory() + assert isinstance(app, FastAPI) + assert app.router.lifespan_context == lifespan + + +@patch("app.main.uvicorn.run") +def test_main(mock_uvicorn_run: Mock) -> None: + main() + mock_uvicorn_run.assert_called_once_with( + app_factory, + factory=True, + host="0.0.0.0", + port=8000, + )