Compare commits

..

3 Commits

Author SHA1 Message Date
2f27527c2e 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
Reviewed-on: #8
2026-04-26 19:36:26 +00:00
9a6b608c73 feat: implement blog project with CI pipeline
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
2026-04-26 21:12:17 +03:00
2e930ffbe5 feat: implement blog project with CI pipeline
All checks were successful
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
2026-04-26 21:08:49 +03:00
23 changed files with 372 additions and 296 deletions

9
.gitignore vendored
View File

@@ -2,7 +2,7 @@
site/
# Python cache (ignore all)
__pycache__/
**/__pycache__/
*.py[cod]
*$py.class
*.pyc
@@ -11,13 +11,10 @@ __pycache__/
# opencode skills (agent-only)
.opencode/
AGENTS.md
.github/
# Scripts (except hooks)
scripts/*
!scripts/commit-msg
!scripts/post-commit
!scripts/update_readme.py
!scripts/clean_cache.sh
scripts/
# IDE
.idea/

View File

@@ -31,22 +31,4 @@ 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 ]

View 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

View File

@@ -1,18 +0,0 @@
when:
event: [push]
branch: main
steps:
deploy:
image: python:3.13
commands:
- echo "🚀 Deploying to production..."
- echo "Branch: main"
- echo "Commit: $(git rev-parse --short HEAD)"
# Add your deployment commands here
# Example:
# - uv sync --frozen
# - uv run python -m app.main &
- echo "✅ Deployment complete"
when:
status: [success]

View File

@@ -1,49 +0,0 @@
when:
event: [push, pull_request]
steps:
check-branch:
image: alpine/git
commands:
- BRANCH=$(git rev-parse --abbrev-ref HEAD)
- |
echo "Branch: $BRANCH"
if [ "$BRANCH" = "main" ]; then
echo "✓ Production branch (protected)"
elif [ "$BRANCH" = "dev" ]; then
echo "✓ Development branch (protected)"
elif echo "$BRANCH" | grep -qE "^feature/"; then
echo "✓ Feature branch"
elif echo "$BRANCH" | grep -qE "^(fix|hotfix|release)/"; then
echo "✓ Special branch"
else
echo "⚠️ Unusual branch name: $BRANCH"
echo " Recommended: feature/<description>, fix/<description>"
fi
check-commit-message:
image: alpine/git
commands:
- MSG=$(git log -1 --pretty=%s)
- |
echo "Last commit: $MSG"
if echo "$MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore): [a-z]"; then
echo "✓ Commit message follows convention"
else
echo "❌ Invalid commit message format"
echo " Expected: <type>: <description>"
echo " Types: feat, fix, docs, style, refactor, test, chore"
exit 1
fi
check-cache-files:
image: python:3.13
commands:
- |
CACHE_FILES=$(git diff --name-only HEAD~1 | grep -E "__pycache__|\.pyc$" || true)
if [ -n "$CACHE_FILES" ]; then
echo "❌ Cache files in commit:"
echo "$CACHE_FILES"
exit 1
fi
- echo "✓ No cache files in commit"

View File

@@ -1,6 +1,6 @@
when:
- event: [push, pull_request]
branch: main
branch: dev
steps:
- name: lint
@@ -11,3 +11,4 @@ steps:
- uv run black --check .
- uv run ruff check .
- uv run isort --check-only .

View File

@@ -1,12 +1,11 @@
when:
- event: [push, pull_request]
branch: main
branch: dev
steps:
- name: tests
- name: test
image: python:3.11
commands:
- pip install uv
- uv sync --no-dev --group tests
- uv run pytest --cov=app --cov-fail-under=70 --cov-report=term-missing

View File

@@ -1,9 +1,9 @@
when:
- event: [push, pull_request]
branch: main
branch: dev
steps:
- name: types
- name: type
image: python:3.11
commands:
- pip install uv

145
README.md
View File

@@ -1,146 +1,5 @@
# blog.pyaqa.ru
Блог pyaqa
[![status-badge](https://cicd.pyaqa.ru/api/badges/2/status.svg)](https://cicd.pyaqa.ru/repos/2)
Блог pyaqa - FastAPI приложение для блога.
## Features
- FastAPI REST API
- Python 3.13+
- Async/await support
- Type hints throughout
- Comprehensive testing
- Auto-generated documentation
## Requirements
- Python 3.13+
- uv package manager
## Installation
```bash
uv sync
```
## Usage
```bash
uv run python -m app.main
```
Server runs on `http://0.0.0.0:8000`
API documentation:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## Available Commands
| Command | Description |
|---------|-------------|
| `uv sync` | Install dependencies |
| `uv run python -m app.main` | Start development server |
| `uv run pytest --cov=app --cov-fail-under=70` | Run tests with coverage |
| `uv run ruff check . --fix` | Run linters |
| `uv run ruff format .` | Format code |
| `uv run isort . --profile black --filter-files` | Sort imports |
| `uv run mypy .` | Type checking |
| `uv run mkdocs build` | Build documentation |
| `uv run mkdocs serve` | Serve documentation locally |
## Dependencies
### Runtime
- dishka>=1.0.0
- fastapi>=0.136.0
- pydantic-settings>=2.7.0
- uvicorn>=0.44.0
### Development
- **Tests**: httpx>=0.28.1, mimesis>=13.0.0, pytest-asyncio>=1.3.0, pytest-cov>=7.1.0, pytest>=9.0.3
- **Lint**: black>=23.7.0, isort>=8.0.1, ruff>=0.15.11
- **Types**: mypy>=1.20.1
- **Docs**: interrogate>=1.7.0, mkdocs>=1.6.0, mkdocstrings[python]>=0.24.0, pydocstyle>=6.3.0
## Project Structure
```
app/
├── main.py
├── core/
│ ├── config.py
│ └── exceptions.py
├── api/
│ └── v1/
├── modules/
└── common/
└── error_handler.py
tests/
├── api/
├── unit/
├── integration/
└── e2e/
docs/
├── api/
└── development/
```
## Architecture
Layered Architecture:
- **API Layer** - HTTP endpoints, request/response handling
- **Service Layer** - Business logic, orchestration
- **Repository Layer** - Data access abstraction
- **Database** - Persistence
Dependency Injection: **Dishka** (not FastAPI Depends)
## Testing
```bash
# Run all tests
uv run pytest --cov=app --cov-fail-under=70
# Run by type
uv run pytest tests/unit/ -v
uv run pytest tests/api/ -v
uv run pytest -m unit -v
uv run pytest -m api -v
```
## Documentation
```bash
# Build docs
uv run mkdocs build
# Serve locally
uv run mkdocs serve
```
## CI/CD
Woodpecker CI is configured in `.woodpecker/` directory.
## License
See [LICENSE](LICENSE) file.
## Changelog
### [v0.1.0] - 2026-04-25
#### Added
- feat: update project structure and docs (9772c3c)
- [QG] Add quality gates on main branch (9c3b44b)
- [Lint] add pipeline (fa8751c)
#### Changed
- pipeline updated (7cd0d8c)
#### Other
- refactor: remove all inline comments from code (c85e981)
- Dev dependencies (0480712)

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
@@ -11,7 +11,7 @@ from app.core.exceptions import AppException
class ErrorResponse(BaseModel):
status_code: int
message: str
details: dict | None = None
details: dict[str, str] | None = None
timestamp: str
@@ -21,7 +21,7 @@ async def app_exception_handler(request: Request, exc: AppException) -> JSONResp
content={
"status_code": exc.status_code,
"message": exc.message,
"timestamp": datetime.utcnow().isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
@@ -32,12 +32,12 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
content={
"status_code": exc.status_code,
"message": str(exc.detail),
"timestamp": datetime.utcnow().isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
def register_exception_handlers(app: FastAPI):
def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(
AppException,
app_exception_handler, # type: ignore[arg-type]

View File

@@ -1,27 +1,22 @@
from contextlib import asynccontextmanager
from typing import AsyncGenerator
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):
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
yield
def app_factory() -> FastAPI:
app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)
register_exception_handlers(app)
app = FastAPI(lifespan=lifespan)
return app
def main():
uvicorn.run(app_factory, factory=True, host=settings.host, port=settings.port)
def main() -> None:
uvicorn.run(app_factory, factory=True, host="0.0.0.0", port=8000)
if __name__ == "__main__":

View File

@@ -6,9 +6,9 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.136.0",
"pydantic>=2.13.2",
"pydantic-settings>=2.14.0",
"uvicorn>=0.44.0",
"pydantic-settings>=2.7.0",
"dishka>=1.0.0",
]
[dependency-groups]
@@ -16,15 +16,14 @@ dev = [
{include-group = "lints"},
{include-group = "tests"},
{include-group = "types"},
{include-group = "docs"},
"pre-commit>=4.5.1",
]
tests = [
"httpx>=0.28.1",
"mimesis>=19.1.0",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.1.0",
"mimesis>=13.0.0",
]
lints = [
"black>=23.7.0",
@@ -32,33 +31,23 @@ lints = [
"isort>=8.0.1",
]
types = [
"mimesis>=19.1.0",
"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=app --cov-report=term --no-cov-on-fail -p no:cacheprovider"
addopts = "--cov=src --cov-report=term"
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
[tool.mypy]
strict = true
plugins = ["pydantic.mypy"]
[tool.isort]
profile = "black"
filter_files = true

View File

@@ -342,7 +342,7 @@ def update_readme(check_only: bool = False) -> bool:
return False
def main():
def main() -> None:
import sys
check_only = "--check" in sys.argv

View File

@@ -1,12 +1,14 @@
# 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():
async def client() -> AsyncGenerator[AsyncClient, None]:
"""Create async HTTP client for API testing."""
from app.main import app_factory
@@ -17,6 +19,6 @@ async def client():
@pytest.fixture
def auth_headers():
def auth_headers() -> dict[str, str]:
"""Return mock authentication headers."""
return {"Authorization": "Bearer test_token"}

View File

@@ -1,8 +1,8 @@
from asyncio import AbstractEventLoopPolicy, DefaultEventLoopPolicy
import pytest
@pytest.fixture(scope="session")
def event_loop_policy():
import asyncio
return asyncio.DefaultEventLoopPolicy()
def event_loop_policy() -> AbstractEventLoopPolicy:
return DefaultEventLoopPolicy()

View File

@@ -1,11 +1,14 @@
# 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():
async def e2e_app() -> AsyncGenerator[FastAPI, None]:
"""Create full application instance for E2E testing."""
from app.main import app_factory
@@ -15,7 +18,7 @@ async def e2e_app():
@pytest.fixture
def e2e_user_data():
def e2e_user_data() -> dict[str, str]:
"""Generate realistic user data for E2E scenarios."""
from mimesis import Person

View File

@@ -1,18 +1,20 @@
# Integration test fixtures
# Provides: test database, external service connections
from typing import Generator
import pytest
@pytest.fixture
def test_db_connection():
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():
def cleanup_db() -> Generator[None, None, None]:
"""Cleanup database after test."""
yield
# TODO: Implement cleanup logic

41
tests/test_app_run.py Normal file
View File

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

View File

@@ -7,12 +7,12 @@ import pytest
@pytest.fixture
def mock_service():
def mock_service() -> Mock:
"""Create a mock service for unit testing."""
return Mock()
@pytest.fixture
def mock_async_service():
def mock_async_service() -> AsyncMock:
"""Create an async mock service for unit testing."""
return AsyncMock()

52
tests/unit/test_config.py Normal file
View 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"

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

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

View File

@@ -1,5 +1,5 @@
from contextlib import asynccontextmanager
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
from fastapi import FastAPI
@@ -8,22 +8,22 @@ from app.main import app_factory, lifespan, main
@pytest.mark.asyncio
async def test_lifespan():
async def test_lifespan() -> None:
app = FastAPI()
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__)
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type]
async with lifespan(app):
pass
def test_app_factory():
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):
def test_main(mock_uvicorn_run: Mock) -> None:
main()
mock_uvicorn_run.assert_called_once_with(
app_factory,