From 9772c3c908daa9302fa0a27d33f06acedd2fa35f Mon Sep 17 00:00:00 2001 From: Sergey Vanyushkin Date: Sat, 25 Apr 2026 16:26:33 +0300 Subject: [PATCH] feat: update project structure and docs --- .coverage | Bin 53248 -> 0 bytes .github/PULL_REQUEST_TEMPLATE.md | 30 ++ .gitignore | 51 +++ .pre-commit-config.yaml | 18 + .woodpecker/deploy.yaml | 18 + .woodpecker/git-check.yaml | 49 +++ README.md | 146 ++++++- app/__init__.py | 1 + app/__pycache__/__init__.cpython-313.pyc | Bin 139 -> 0 bytes app/__pycache__/main.cpython-313.pyc | Bin 876 -> 0 bytes app/api/__init__.py | 1 + app/api/v1/__init__.py | 1 + app/common/__init__.py | 1 + app/common/error_handler.py | 55 +++ app/core/__init__.py | 1 + app/core/config.py | 20 + app/core/exceptions.py | 64 +++ app/main.py | 28 +- app/modules/__init__.py | 1 + docs/api/endpoints.md | 17 + docs/api/index.md | 13 + docs/development/codestyle.md | 43 ++ docs/development/setup.md | 31 ++ docs/index.md | 28 ++ mkdocs.yml | 50 +++ pyproject.toml | 25 +- scripts/README.md | 65 +++ scripts/clean_cache.sh | 30 ++ scripts/commit-msg | 70 ++++ scripts/post-commit | 18 + scripts/update_readme.py | 371 ++++++++++++++++++ .../test_app_run.cpython-313-pytest-9.0.3.pyc | Bin 5440 -> 0 bytes tests/api/__init__.py | 0 tests/api/conftest.py | 22 ++ tests/conftest.py | 17 + tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 27 ++ tests/integration/__init__.py | 0 tests/integration/conftest.py | 18 + tests/unit/__init__.py | 0 tests/unit/conftest.py | 18 + .../test_unit_app_run.py} | 0 42 files changed, 1342 insertions(+), 6 deletions(-) delete mode 100644 .coverage create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 .woodpecker/deploy.yaml create mode 100644 .woodpecker/git-check.yaml delete mode 100644 app/__pycache__/__init__.cpython-313.pyc delete mode 100644 app/__pycache__/main.cpython-313.pyc create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/common/__init__.py create mode 100644 app/common/error_handler.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/exceptions.py create mode 100644 app/modules/__init__.py create mode 100644 docs/api/endpoints.md create mode 100644 docs/api/index.md create mode 100644 docs/development/codestyle.md create mode 100644 docs/development/setup.md create mode 100644 docs/index.md create mode 100644 mkdocs.yml create mode 100644 scripts/README.md create mode 100755 scripts/clean_cache.sh create mode 100755 scripts/commit-msg create mode 100755 scripts/post-commit create mode 100644 scripts/update_readme.py delete mode 100644 tests/__pycache__/test_app_run.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/api/__init__.py create mode 100644 tests/api/conftest.py create mode 100644 tests/conftest.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/conftest.py rename tests/{test_app_run.py => unit/test_unit_app_run.py} (100%) diff --git a/.coverage b/.coverage deleted file mode 100644 index 7e68d20af959b5942b697a700d297ceb68ebd4a8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI)U2oe|7zc1W?vf@=abcRGQcXQK(3RzdXhOVQK!Hk3OoFk2*bUxEa*`NgJGGsy zMM9`!()bLB>+SQvC*X2dOi1GbjSHT~$IeUFY`d$L{jD~2>~oIK^E+>G()P;bOKz-0 zHwZl$iwnk@VVcH=LKucor0-Sw&b9*WEM-sVr#ZDhZ?|aFf7n~Kzc$MGZw&j})mQCW zxmW&s<+t)X#jjS}LbX_@6WAaC0SG`~VFV5@mkX8kb@SGbv1|=g9Llx|_2cTtU+(T) z-4$1NKDx9k^f7U+BxtME#EuAqn_{R!(RBw(xPHfN%h>gMBJQg!^(ayuUbE;Jty-M0 zp5*5}$L&zASoNrgVd#1?JP_ZjgKCl>y;`jHV|{=MQEt!Y6yjW(eOrX8t3u_sRitZi z&h2cMwtxPqRH&RjZQfByOfqcK*UC&ZbYK$7#V!@p4#E!Cz7@2fauAHkYs7&VxPB6*AGxs`_(JWg_9#}JV*{)*TDB41bTm}fbq?sd&Y50oN@tS!n4D?y zG;^0K=LZ?}<>nCr#%x)1BqR!cw7f=8So$M+DDH{C#RB z2tG0y0!J2tXRrQH#(nX4-T5$8eCt%c^8T5573V5AO@7SZUdts_{>4i4J(~3S+nT8| zADLA7Gj&kAAqO;@45b^=kfk|TuKzFr$B$Hj=Q=|&={!{e*c2a__Q11_U? zV=Jjswz%*#f0YJZHoj=b^Q7NN*DGFzb*VJ>^Wfy`=t+b!iY*$H5e?BJWoCt@XL-iT z%C8T}giG~gM-O*yl&KPL_SIv>>DA~H8LQa!l;9*uWs;6{Q%0g4Dmt02rAfs|tbept zx|mPtOg4w=XtuYkC-og}VyDTEe3!h;FXaw5%|hk;dGm0Tj$l1GIT5`Q@MIQi`ZPDw zvyPmcbh6KBmZq0T)18aKrwsY2z5>sPy4hQrE=!fZKq_tX4ZdkVGUy8%1Rwwb2tWV= z5P$##AOHafKmY=ZCt#U5v%vTNIr~q;{)-;4K>z{}fB*y_009U<00Izz00bcLTndzP z)>%9IwU9U0OlxC3{T+a}>&*-Gw@MUM&c1Kh_w9e4%K%Yn2tWV=5P$##AOHafKmY;| zfB*!>0%hy0nY|5=D_I-G^j!e``Tt$R{@uPiPK+H0KmY;|fB*y_009U<00Izz00d4z zU^{0S*Bbr6Q;nf}q1_18FlY=9rZ6J+c`00Izz00bZa0SG_<0uX=z1Qt-hqTdu)< + +## 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..faa86cd --- /dev/null +++ b/.gitignore @@ -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__/ + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f8e7e11..6c17fee 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 ] + diff --git a/.woodpecker/deploy.yaml b/.woodpecker/deploy.yaml new file mode 100644 index 0000000..15d4cc2 --- /dev/null +++ b/.woodpecker/deploy.yaml @@ -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] diff --git a/.woodpecker/git-check.yaml b/.woodpecker/git-check.yaml new file mode 100644 index 0000000..1519091 --- /dev/null +++ b/.woodpecker/git-check.yaml @@ -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/, fix/" + 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: : " + 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" diff --git a/README.md b/README.md index 24c1d09..2fa9d4b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,147 @@ # 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 (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) 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 e6ff2942773d1a0c63e04272b11b4db25c42ae7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 139 zcmey&%ge<81XFdMW`gL)AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekklk6v1k{HmJaOu32V2u0x|B z&9JDU+k@31?kd4*bJ;wL;2F$-ZD4=;blXr=skNe8{<5kv%DjqH-iGwESasAWM(CeT zpzFeq%EZUpUn?`+2j8mW-K7G9@E;IyYoG-b2t*0mo(_Qq5rAn}B@AsYa}AffVjiEl z33h3DWyicetAF9W%lKkEH~Ubz*o@OG7fGx#3U6(PeypOLtF1@_E22Oh=;l&w>*#1J`=_|FV! zMEU|Mk7PUWywecYop!vw)e7#aNld1PN*|%?amLsoTswmCL%7r%0pmi?V65`NEC82+ i4+1Ba!Oomi7+YjL3&iN#odZ7kU0AOddmO}+hW`VD^{hMq 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..7ea95bd --- /dev/null +++ b/app/common/error_handler.py @@ -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] + ) 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..d46aca0 --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..b707bca --- /dev/null +++ b/app/core/exceptions.py @@ -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) diff --git a/app/main.py b/app/main.py index 70dbf6e..bb0caa5 100644 --- a/app/main.py +++ b/app/main.py @@ -1,21 +1,43 @@ +"""FastAPI application factory and entry point.""" + 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): + """Application lifespan manager for startup/shutdown events.""" + # Startup: initialize DB connections, cache, etc. yield + # Shutdown: cleanup resources -def app_factory(): - app = FastAPI(lifespan=lifespan) +def app_factory() -> FastAPI: + """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 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__": 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..30164e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 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..54f22ad --- /dev/null +++ b/scripts/clean_cache.sh @@ -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" diff --git a/scripts/commit-msg b/scripts/commit-msg new file mode 100755 index 0000000..e56f38c --- /dev/null +++ b/scripts/commit-msg @@ -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 " + 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: : " + 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 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..f7db2bc --- /dev/null +++ b/scripts/update_readme.py @@ -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() 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 a8580fb0ddc6f94de8e3d6e7214a571be6758e15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5440 zcmdT|UrZdw8K1q~+xw3p2K)y(-k21zDL(!i+bvbB{NW4=t+?>gi*#Ba2b^-;UT2Ra zP$6NGDvg|oR7#{gMCx;;JRr+%P->G`9(RQeXq70Hnm**s36&zXedzbi?(8mb4MvJ8 zb?_LS&|jT2o=~jG%6Y) zg^WrxtT-hym7ydw78#Aw=xB__7+)NVk0xk>(b8CQG(}U44v#gAo}ed28)@Tc6Kzt+ zEAqtC+_3+h=$HbkV446+59- ztNCfIP}AvxEzN1AO2Ku7eXuj%gm;fhgtQX{x-I8AMLee~B*(j!eqhxKrl|$u;oeiAGNO z-wPxc(YmV@;v-~yTLvcKugq7e)l9lntXYBRRL5UeTO2%8^jXXuV z2rGwZbZUr_B;gS<1rakHQpkCzRUtF-uy~$K3q#_xpho8318>YfTFd5!dE2~eEnBOQ zu5>BZ+t!M?q40%F0sd|CFHWg-(^^%`&&&-FUpDWWpMmIo>y~*>G1sh{<~n44$7;OK z*&qw#3X{F*OWy;=eR%Jfe@5FNbT7R~HNz;cSP(i2v3llwro|QH@UI^r}V| zV+?T_AY7S2&H}<^^|@;Os-ec0()oO$40iJQ_!4C8c*!VLjG9&{6lnx{gyNE_Mr^UV z(1S@YCViOnV={=z5GGf~)g;9cWg1M^j(L^S1eDmxXR8bO8}p@d4Q$wQSubd1!;S;D zRy1mPjoPR4#;iVHp32i=mF8#4`VFm|uV`~csLRl%i^#ScuCab8>6I5K)v0|lKdsX_ zt(GsoQ7yyPQ-cyUY>Tkt*aB=h8>|Tq(^-Ekhc${3^ca!^he_A(tAt0(d73cX*{>O z66u}@@C#6v_h95AQrJ5SLwOXEm<(bGjtZLingOWbr!Fe+(8Wa5v*ZzVP{9!> zB6nyn;x-v7@U=a3;Ta=<3Jz(zsNgtWwgLL^?bO9O{@Uz$2Wx#mtFCvxmWu+8;}s&R zBfRpF9N$?y?+{V>+I77dtoTo#K$C{U4M7>_FV2U?u8X$l1w!7 zI{dHRKT&5#)thpsxy;oJA^P*Y)exm%HQ)pE!odfCeq>S_5VFxp!LMf^H0UBXKtabD zrT&x_o3FYc3zshIou+<8G9Nvx#hSGTbAu zn3InYb29gae_i|~0{&{Ak=4}vI}jy5fO-OlHU$99hWRN#lr`%Q){0_&4mf2Eu+Tc7 zp?3kjY?yZ)CNLlaKn9rWz;sWEPyv)Pc!NM^4N4uX#5c?I;$Q&r3~=ZI#D7CPfMo!* zFmOR39ti^=szh-mqbW=lwRM6+D8ZD|3)-6G-)9+*)tTPxa=?!Mw5=U$( zsB^`@-Ic*3MWG7%)#$)C|A0?>fQ>CME&=RWCSS)}Ze3h{?u$gr`-P8(wi4~8)V`f) z-;wtHEvX&hjmz7gbJ*Kb`}>8UL?Gu$d^{AC;JJrVyIIfTUlR?hSKqm6N_{(t4lB_C z#3!9rqHhNn?7t=TnTd{1Iz5I%Je2x=4=~CVviuwtKr67oQT2oz;sF})c%T@_dF*Ye zZx6bUEH=XoOyg}ydj7RiL8p~8;LHMas`q$U=Xqe&!#WCqJqFRxfydtk@y-L!>Q^K< z6@^uKz5=2@uqvQGig_CjI-kOO8>TXxl$3yj&Q#yOKH#y)T*nn)jTOL=$69fz_4Lb& zk)AaBm7az57OsvW-eu9OZq#h4s?*xIDpI`7u}?WuiUoXeDzC$FCI5P?jWp=di|>6xKm-!$x1VBkWeL zq_blPo0P1@e%59e=ItN;K2 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..685d9c2 --- /dev/null +++ b/tests/api/conftest.py @@ -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"} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dab3a9a --- /dev/null +++ b/tests/conftest.py @@ -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() 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..296b0c5 --- /dev/null +++ b/tests/e2e/conftest.py @@ -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!", + } 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..5c40ae8 --- /dev/null +++ b/tests/integration/conftest.py @@ -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 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..401c50e --- /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(): + """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() diff --git a/tests/test_app_run.py b/tests/unit/test_unit_app_run.py similarity index 100% rename from tests/test_app_run.py rename to tests/unit/test_unit_app_run.py