Merge pull request 'dev' (#8) from dev into main
Some checks failed
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/type Pipeline was successful
ci/woodpecker/push/comment_pr Pipeline failed
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/type Pipeline was successful
ci/woodpecker/pr/comment_pr Pipeline was successful
Some checks failed
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/type Pipeline was successful
ci/woodpecker/push/comment_pr Pipeline failed
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/type Pipeline was successful
ci/woodpecker/pr/comment_pr Pipeline was successful
Reviewed-on: #8
This commit is contained in:
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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 -->
|
||||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -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__/
|
||||||
|
|
||||||
24
.woodpecker/comment_pr.yaml
Normal file
24
.woodpecker/comment_pr.yaml
Normal file
@@ -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
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
when:
|
when:
|
||||||
- event: [push, pull_request]
|
- event: [push, pull_request]
|
||||||
branch: main
|
branch: dev
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: lint
|
- name: lint
|
||||||
@@ -11,3 +11,4 @@ steps:
|
|||||||
- uv run black --check .
|
- uv run black --check .
|
||||||
- uv run ruff check .
|
- uv run ruff check .
|
||||||
- uv run isort --check-only .
|
- uv run isort --check-only .
|
||||||
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
when:
|
when:
|
||||||
- event: [push, pull_request]
|
- event: [push, pull_request]
|
||||||
branch: main
|
branch: dev
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: tests
|
- name: test
|
||||||
image: python:3.11
|
image: python:3.11
|
||||||
commands:
|
commands:
|
||||||
- pip install uv
|
- pip install uv
|
||||||
- uv sync --no-dev --group tests
|
- uv sync --no-dev --group tests
|
||||||
- uv run pytest --cov=app --cov-fail-under=70 --cov-report=term-missing
|
- uv run pytest --cov=app --cov-fail-under=70 --cov-report=term-missing
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
when:
|
when:
|
||||||
- event: [push, pull_request]
|
- event: [push, pull_request]
|
||||||
branch: main
|
branch: dev
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: types
|
- name: type
|
||||||
image: python:3.11
|
image: python:3.11
|
||||||
commands:
|
commands:
|
||||||
- pip install uv
|
- pip install uv
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application package."""
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API module - HTTP routes and endpoints."""
|
||||||
1
app/api/v1/__init__.py
Normal file
1
app/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API version 1 endpoints."""
|
||||||
1
app/common/__init__.py
Normal file
1
app/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Common utilities and shared components."""
|
||||||
48
app/common/error_handler.py
Normal file
48
app/common/error_handler.py
Normal file
@@ -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]
|
||||||
|
)
|
||||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Core module - shared functionality and configuration."""
|
||||||
15
app/core/config.py
Normal file
15
app/core/config.py
Normal 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
25
app/core/exceptions.py
Normal 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)
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
def app_factory():
|
def app_factory() -> FastAPI:
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
uvicorn.run(app_factory, factory=True, host="0.0.0.0", port=8000)
|
uvicorn.run(app_factory, factory=True, host="0.0.0.0", port=8000)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
app/modules/__init__.py
Normal file
1
app/modules/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Feature modules - business logic organized by domain."""
|
||||||
17
docs/api/endpoints.md
Normal file
17
docs/api/endpoints.md
Normal 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
13
docs/api/index.md
Normal 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
|
||||||
43
docs/development/codestyle.md
Normal file
43
docs/development/codestyle.md
Normal 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
31
docs/development/setup.md
Normal 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
28
docs/index.md
Normal 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
50
mkdocs.yml
Normal 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
|
||||||
@@ -6,6 +6,8 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.136.0",
|
"fastapi>=0.136.0",
|
||||||
|
"pydantic>=2.13.2",
|
||||||
|
"pydantic-settings>=2.14.0",
|
||||||
"uvicorn>=0.44.0",
|
"uvicorn>=0.44.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ dev = [
|
|||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"mimesis>=19.1.0",
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
@@ -28,6 +31,7 @@ lints = [
|
|||||||
"isort>=8.0.1",
|
"isort>=8.0.1",
|
||||||
]
|
]
|
||||||
types = [
|
types = [
|
||||||
|
"mimesis>=19.1.0",
|
||||||
"mypy>=1.20.1",
|
"mypy>=1.20.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -39,3 +43,11 @@ pythonpath = "."
|
|||||||
testpaths = "tests"
|
testpaths = "tests"
|
||||||
xfail_strict = true
|
xfail_strict = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
filter_files = true
|
||||||
|
|
||||||
|
|||||||
65
scripts/README.md
Normal file
65
scripts/README.md
Normal 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
22
scripts/clean_cache.sh
Executable 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
64
scripts/commit-msg
Executable 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
18
scripts/post-commit
Executable 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
358
scripts/update_readme.py
Normal 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() -> 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()
|
||||||
Binary file not shown.
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
24
tests/api/conftest.py
Normal file
24
tests/api/conftest.py
Normal file
@@ -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"}
|
||||||
8
tests/conftest.py
Normal file
8
tests/conftest.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from asyncio import AbstractEventLoopPolicy, DefaultEventLoopPolicy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop_policy() -> AbstractEventLoopPolicy:
|
||||||
|
return DefaultEventLoopPolicy()
|
||||||
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
30
tests/e2e/conftest.py
Normal file
30
tests/e2e/conftest.py
Normal file
@@ -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!",
|
||||||
|
}
|
||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
20
tests/integration/conftest.py
Normal file
20
tests/integration/conftest.py
Normal file
@@ -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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from unittest.mock import patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -10,19 +10,19 @@ from app.main import app_factory, lifespan, main
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_lifespan():
|
async def test_lifespan() -> None:
|
||||||
"""Проверяет, что lifespan является корректным асинхронным контекстным менеджером."""
|
"""Проверяет, что lifespan является корректным асинхронным контекстным менеджером."""
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
# Проверяем, что lifespan - это asynccontextmanager
|
# Проверяем, что lifespan - это asynccontextmanager
|
||||||
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__)
|
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type]
|
||||||
|
|
||||||
# Проверяем, что контекстный менеджер работает (ничего не ломается)
|
# Проверяем, что контекстный менеджер работает (ничего не ломается)
|
||||||
async with lifespan(app):
|
async with lifespan(app):
|
||||||
pass # Просто убеждаемся, что yield отрабатывает
|
pass # Просто убеждаемся, что yield отрабатывает
|
||||||
|
|
||||||
|
|
||||||
def test_app_factory():
|
def test_app_factory() -> None:
|
||||||
"""Проверяет, что app_factory создаёт правильное приложение FastAPI с переданным lifespan."""
|
"""Проверяет, что app_factory создаёт приложение FastAPI с переданным lifespan."""
|
||||||
app = app_factory()
|
app = app_factory()
|
||||||
assert isinstance(app, FastAPI)
|
assert isinstance(app, FastAPI)
|
||||||
# Проверяем, что lifespan приложения установлен на функцию lifespan
|
# Проверяем, что lifespan приложения установлен на функцию lifespan
|
||||||
@@ -30,7 +30,7 @@ def test_app_factory():
|
|||||||
|
|
||||||
|
|
||||||
@patch("app.main.uvicorn.run")
|
@patch("app.main.uvicorn.run")
|
||||||
def test_main(mock_uvicorn_run):
|
def test_main(mock_uvicorn_run: Mock) -> None:
|
||||||
"""Проверяет, что main вызывает uvicorn.run с правильными параметрами."""
|
"""Проверяет, что main вызывает uvicorn.run с правильными параметрами."""
|
||||||
main()
|
main()
|
||||||
mock_uvicorn_run.assert_called_once_with(
|
mock_uvicorn_run.assert_called_once_with(
|
||||||
|
|||||||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
18
tests/unit/conftest.py
Normal file
18
tests/unit/conftest.py
Normal 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() -> 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()
|
||||||
52
tests/unit/test_config.py
Normal file
52
tests/unit/test_config.py
Normal file
@@ -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"
|
||||||
110
tests/unit/test_error_handler.py
Normal file
110
tests/unit/test_error_handler.py
Normal file
@@ -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)
|
||||||
87
tests/unit/test_exceptions.py
Normal file
87
tests/unit/test_exceptions.py
Normal file
@@ -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)
|
||||||
33
tests/unit/test_unit_app_run.py
Normal file
33
tests/unit/test_unit_app_run.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user