style: apply ruff formatting to source and test files
All checks were successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/type Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful

This commit is contained in:
2026-05-02 12:05:14 +03:00
parent 1dbedf0f52
commit 14adcaa3e6
16 changed files with 50 additions and 95 deletions

View File

@@ -27,9 +27,7 @@ class CreatePostUseCase:
# Check if slug already exists # Check if slug already exists
if await self._post_repo.slug_exists(slug.value): if await self._post_repo.slug_exists(slug.value):
raise AlreadyExistsException( raise AlreadyExistsException(f"Post with slug '{slug.value}' already exists")
f"Post with slug '{slug.value}' already exists"
)
# Create domain entity # Create domain entity
post = Post.create( post = Post.create(

View File

@@ -38,9 +38,7 @@ class ListPostsUseCase:
offset: int | None = None, offset: int | None = None,
) -> list[PostResponseDTO]: ) -> list[PostResponseDTO]:
"""Get posts by author.""" """Get posts by author."""
posts = await self._post_repo.get_by_author( posts = await self._post_repo.get_by_author(author_id, limit=limit, offset=offset)
author_id, limit=limit, offset=offset
)
return [self._map_to_dto(post) for post in posts] return [self._map_to_dto(post) for post in posts]
async def by_tag( async def by_tag(

View File

@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import UTC, datetime
from typing import Any from typing import Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -12,8 +12,8 @@ class BaseEntity(ABC):
"""Base class for all domain entities.""" """Base class for all domain entities."""
id: UUID = field(default_factory=uuid4) id: UUID = field(default_factory=uuid4)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
if not isinstance(other, BaseEntity): if not isinstance(other, BaseEntity):
@@ -25,7 +25,7 @@ class BaseEntity(ABC):
def touch(self) -> None: def touch(self) -> None:
"""Update the updated_at timestamp.""" """Update the updated_at timestamp."""
self.updated_at = datetime.now(timezone.utc) self.updated_at = datetime.now(UTC)
@abstractmethod @abstractmethod
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:

View File

@@ -1,8 +1,9 @@
"""Role-based access control definitions.""" """Role-based access control definitions."""
from collections.abc import Callable
from enum import Enum from enum import Enum
from functools import wraps from functools import wraps
from typing import Any, Callable from typing import Any
from app.domain.exceptions import ForbiddenException from app.domain.exceptions import ForbiddenException

View File

@@ -19,9 +19,7 @@ class Slug(ValueObject[str]):
if len(self.value) > self.MAX_LENGTH: if len(self.value) > self.MAX_LENGTH:
raise ValueError(f"Slug must be at most {self.MAX_LENGTH} characters") raise ValueError(f"Slug must be at most {self.MAX_LENGTH} characters")
if not re.match(self.SLUG_PATTERN, self.value): if not re.match(self.SLUG_PATTERN, self.value):
raise ValueError( raise ValueError("Slug must contain only lowercase letters, numbers, and hyphens")
"Slug must contain only lowercase letters, numbers, and hyphens"
)
@classmethod @classmethod
def from_title(cls, title: str) -> "Slug": def from_title(cls, title: str) -> "Slug":

View File

@@ -1,7 +1,7 @@
"""Database connection and session management.""" """Database connection and session management."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncEngine, AsyncEngine,
@@ -38,7 +38,7 @@ AsyncSessionLocal = async_sessionmaker(
) )
async def get_session() -> AsyncGenerator[AsyncSession, None]: async def get_session() -> AsyncGenerator[AsyncSession]:
"""Get database session.""" """Get database session."""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
try: try:
@@ -48,7 +48,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
@asynccontextmanager @asynccontextmanager
async def get_session_context() -> AsyncGenerator[AsyncSession, None]: async def get_session_context() -> AsyncGenerator[AsyncSession]:
"""Get database session as context manager.""" """Get database session as context manager."""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
try: try:

View File

@@ -1,6 +1,6 @@
"""SQLAlchemy ORM models.""" """SQLAlchemy ORM models."""
from datetime import datetime, timezone from datetime import UTC, datetime
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import JSON, Boolean, DateTime, String, Text from sqlalchemy import JSON, Boolean, DateTime, String, Text
@@ -14,27 +14,21 @@ class PostORM(Base): # type: ignore[valid-type,misc]
__tablename__ = "posts" __tablename__ = "posts"
id: Mapped[str] = mapped_column( id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
String(36), primary_key=True, default=lambda: str(uuid4())
)
title: Mapped[str] = mapped_column(String(200), nullable=False) title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False)
slug: Mapped[str] = mapped_column( slug: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
String(200), nullable=False, unique=True, index=True
)
author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
published: Mapped[bool] = mapped_column( published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
Boolean, default=False, nullable=False, index=True
)
tags: Mapped[list[str]] = mapped_column(JSON, default=list) tags: Mapped[list[str]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc), default=lambda: datetime.now(UTC),
nullable=False, nullable=False,
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc), default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(UTC),
nullable=False, nullable=False,
) )

View File

@@ -1,6 +1,6 @@
"""Dishka providers for dependency injection.""" """Dishka providers for dependency injection."""
from typing import AsyncGenerator from collections.abc import AsyncGenerator
from dishka import Provider, Scope, provide from dishka import Provider, Scope, provide
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
@@ -30,7 +30,7 @@ class DatabaseProvider(Provider):
return engine return engine
@provide(scope=Scope.REQUEST) @provide(scope=Scope.REQUEST)
async def get_session(self) -> AsyncGenerator[AsyncSession, None]: async def get_session(self) -> AsyncGenerator[AsyncSession]:
"""Provide database session per request.""" """Provide database session per request."""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
try: try:

View File

@@ -1,6 +1,6 @@
"""Exception handling middleware.""" """Exception handling middleware."""
from datetime import datetime, timezone from datetime import UTC, datetime
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -33,9 +33,7 @@ def get_status_code(exc: DomainException) -> int:
return 500 return 500
async def domain_exception_handler( async def domain_exception_handler(request: Request, exc: DomainException) -> JSONResponse:
request: Request, exc: DomainException
) -> JSONResponse:
"""Handle domain exceptions.""" """Handle domain exceptions."""
status_code = get_status_code(exc) status_code = get_status_code(exc)
return JSONResponse( return JSONResponse(
@@ -43,22 +41,20 @@ async def domain_exception_handler(
content={ content={
"error": exc.__class__.__name__, "error": exc.__class__.__name__,
"message": exc.message, "message": exc.message,
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"path": str(request.url.path), "path": str(request.url.path),
}, },
) )
async def http_exception_handler( async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
request: Request, exc: StarletteHTTPException
) -> JSONResponse:
"""Handle HTTP exceptions.""" """Handle HTTP exceptions."""
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={ content={
"error": "HTTPException", "error": "HTTPException",
"message": str(exc.detail), "message": str(exc.detail),
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"path": str(request.url.path), "path": str(request.url.path),
}, },
) )
@@ -71,7 +67,7 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes
content={ content={
"error": "InternalServerError", "error": "InternalServerError",
"message": "An unexpected error occurred", "message": "An unexpected error occurred",
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"path": str(request.url.path), "path": str(request.url.path),
}, },
) )

View File

@@ -47,9 +47,7 @@ class SQLAlchemyPostRepository(PostRepository):
async def get_by_id(self, entity_id: UUID) -> Post | None: async def get_by_id(self, entity_id: UUID) -> Post | None:
"""Get post by ID.""" """Get post by ID."""
result = await self._session.execute( result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity_id)))
select(PostORM).where(PostORM.id == str(entity_id))
)
orm = result.scalar_one_or_none() orm = result.scalar_one_or_none()
return self._to_domain(orm) if orm else None return self._to_domain(orm) if orm else None
@@ -67,9 +65,7 @@ class SQLAlchemyPostRepository(PostRepository):
async def update(self, entity: Post) -> None: async def update(self, entity: Post) -> None:
"""Update existing post.""" """Update existing post."""
result = await self._session.execute( result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity.id)))
select(PostORM).where(PostORM.id == str(entity.id))
)
orm = result.scalar_one() orm = result.scalar_one()
orm.title = entity.title.value orm.title = entity.title.value
@@ -83,25 +79,19 @@ class SQLAlchemyPostRepository(PostRepository):
async def delete(self, entity_id: UUID) -> None: async def delete(self, entity_id: UUID) -> None:
"""Delete post by ID.""" """Delete post by ID."""
result = await self._session.execute( result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity_id)))
select(PostORM).where(PostORM.id == str(entity_id))
)
orm = result.scalar_one_or_none() orm = result.scalar_one_or_none()
if orm: if orm:
await self._session.delete(orm) await self._session.delete(orm)
async def exists(self, entity_id: UUID) -> bool: async def exists(self, entity_id: UUID) -> bool:
"""Check if post exists.""" """Check if post exists."""
result = await self._session.execute( result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity_id)))
select(PostORM).where(PostORM.id == str(entity_id))
)
return result.scalar_one_or_none() is not None return result.scalar_one_or_none() is not None
async def get_by_slug(self, slug: str) -> Post | None: async def get_by_slug(self, slug: str) -> Post | None:
"""Get post by slug.""" """Get post by slug."""
result = await self._session.execute( result = await self._session.execute(select(PostORM).where(PostORM.slug == slug))
select(PostORM).where(PostORM.slug == slug)
)
orm = result.scalar_one_or_none() orm = result.scalar_one_or_none()
return self._to_domain(orm) if orm else None return self._to_domain(orm) if orm else None
@@ -154,9 +144,7 @@ class SQLAlchemyPostRepository(PostRepository):
async def slug_exists(self, slug: str) -> bool: async def slug_exists(self, slug: str) -> bool:
"""Check if slug exists.""" """Check if slug exists."""
result = await self._session.execute( result = await self._session.execute(select(PostORM).where(PostORM.slug == slug))
select(PostORM).where(PostORM.slug == slug)
)
return result.scalar_one_or_none() is not None return result.scalar_one_or_none() is not None
async def search( async def search(

View File

@@ -1,7 +1,7 @@
"""Application entry point with DDD architecture.""" """Application entry point with DDD architecture."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncGenerator
import uvicorn import uvicorn
from dishka import make_async_container from dishka import make_async_container
@@ -21,7 +21,7 @@ from app.presentation import router
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
"""Application lifespan manager.""" """Application lifespan manager."""
# Startup # Startup
await init_db() await init_db()

View File

@@ -1,6 +1,6 @@
"""API test fixtures.""" """API test fixtures."""
from typing import AsyncGenerator from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -25,7 +25,7 @@ def mock_keycloak_client() -> MagicMock:
@pytest.fixture @pytest.fixture
async def client(mock_keycloak_client: MagicMock) -> AsyncGenerator[AsyncClient, None]: async def client(mock_keycloak_client: MagicMock) -> AsyncGenerator[AsyncClient]:
"""Create async HTTP client for API testing.""" """Create async HTTP client for API testing."""
with patch( with patch(
"app.presentation.api.deps.KeycloakAuthClient", "app.presentation.api.deps.KeycloakAuthClient",

View File

@@ -1,14 +1,14 @@
# E2E test fixtures # E2E test fixtures
# Provides: full application state, end-to-end workflows, cleanup # Provides: full application state, end-to-end workflows, cleanup
from typing import AsyncGenerator from collections.abc import AsyncGenerator
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
@pytest.fixture @pytest.fixture
async def e2e_app() -> AsyncGenerator[FastAPI, None]: async def e2e_app() -> AsyncGenerator[FastAPI]:
"""Create full application instance for E2E testing.""" """Create full application instance for E2E testing."""
from app.main import app_factory from app.main import app_factory

View File

@@ -1,6 +1,6 @@
"""Integration test fixtures.""" """Integration test fixtures."""
from typing import AsyncGenerator from collections.abc import AsyncGenerator
import pytest import pytest
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
@@ -39,7 +39,7 @@ def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
async def setup_db(engine: AsyncEngine) -> AsyncGenerator[None, None]: async def setup_db(engine: AsyncEngine) -> AsyncGenerator[None]:
"""Setup database tables for each test.""" """Setup database tables for each test."""
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
@@ -51,7 +51,7 @@ async def setup_db(engine: AsyncEngine) -> AsyncGenerator[None, None]:
@pytest.fixture @pytest.fixture
async def db_session( async def db_session(
session_factory: async_sessionmaker[AsyncSession], session_factory: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncSession, None]: ) -> AsyncGenerator[AsyncSession]:
"""Create database session for testing.""" """Create database session for testing."""
async with session_factory() as session: async with session_factory() as session:
yield session yield session

View File

@@ -123,9 +123,7 @@ class TestKeycloakAuthClient:
"""Create Keycloak client.""" """Create Keycloak client."""
return KeycloakAuthClient(settings) return KeycloakAuthClient(settings)
def test_client_initialization( def test_client_initialization(self, client: KeycloakAuthClient, settings: Settings) -> None:
self, client: KeycloakAuthClient, settings: Settings
) -> None:
"""Test client initialization.""" """Test client initialization."""
assert client._settings == settings assert client._settings == settings
assert client._base_url == "http://localhost:8080/realms/test-realm" assert client._base_url == "http://localhost:8080/realms/test-realm"
@@ -144,10 +142,7 @@ class TestKeycloakAuthClient:
def test_get_userinfo_url(self, client: KeycloakAuthClient) -> None: def test_get_userinfo_url(self, client: KeycloakAuthClient) -> None:
"""Test userinfo URL generation.""" """Test userinfo URL generation."""
url = client._get_userinfo_url() url = client._get_userinfo_url()
assert ( assert url == "http://localhost:8080/realms/test-realm/protocol/openid-connect/userinfo"
url
== "http://localhost:8080/realms/test-realm/protocol/openid-connect/userinfo"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_introspect_token_success(self, client: KeycloakAuthClient) -> None: async def test_introspect_token_success(self, client: KeycloakAuthClient) -> None:
@@ -196,18 +191,14 @@ class TestKeycloakAuthClient:
assert result.is_valid is False assert result.is_valid is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_introspect_token_http_error( async def test_introspect_token_http_error(self, client: KeycloakAuthClient) -> None:
self, client: KeycloakAuthClient
) -> None:
"""Test introspection with HTTP error.""" """Test introspection with HTTP error."""
import httpx import httpx
mock_async_client = AsyncMock() mock_async_client = AsyncMock()
mock_async_client.__aenter__ = AsyncMock(return_value=mock_async_client) mock_async_client.__aenter__ = AsyncMock(return_value=mock_async_client)
mock_async_client.__aexit__ = AsyncMock(return_value=None) mock_async_client.__aexit__ = AsyncMock(return_value=None)
mock_async_client.post = AsyncMock( mock_async_client.post = AsyncMock(side_effect=httpx.HTTPError("Connection error"))
side_effect=httpx.HTTPError("Connection error")
)
with patch("httpx.AsyncClient", return_value=mock_async_client): with patch("httpx.AsyncClient", return_value=mock_async_client):
result = await client.introspect_token("test-token") result = await client.introspect_token("test-token")
@@ -216,9 +207,7 @@ class TestKeycloakAuthClient:
assert result.is_valid is False assert result.is_valid is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_introspect_token_uses_cache( async def test_introspect_token_uses_cache(self, client: KeycloakAuthClient) -> None:
self, client: KeycloakAuthClient
) -> None:
"""Test that token introspection uses cache.""" """Test that token introspection uses cache."""
mock_response = Mock() mock_response = Mock()
mock_response.json.return_value = { mock_response.json.return_value = {
@@ -283,9 +272,7 @@ class TestKeycloakAuthClient:
mock_async_client = AsyncMock() mock_async_client = AsyncMock()
mock_async_client.__aenter__ = AsyncMock(return_value=mock_async_client) mock_async_client.__aenter__ = AsyncMock(return_value=mock_async_client)
mock_async_client.__aexit__ = AsyncMock(return_value=None) mock_async_client.__aexit__ = AsyncMock(return_value=None)
mock_async_client.get = AsyncMock( mock_async_client.get = AsyncMock(side_effect=httpx.HTTPError("Connection error"))
side_effect=httpx.HTTPError("Connection error")
)
with patch("httpx.AsyncClient", return_value=mock_async_client): with patch("httpx.AsyncClient", return_value=mock_async_client):
result = await client.get_userinfo("test-token") result = await client.get_userinfo("test-token")
@@ -293,9 +280,7 @@ class TestKeycloakAuthClient:
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_introspect_token_no_realm_roles( async def test_introspect_token_no_realm_roles(self, client: KeycloakAuthClient) -> None:
self, client: KeycloakAuthClient
) -> None:
"""Test introspection without realm_access roles.""" """Test introspection without realm_access roles."""
mock_response = Mock() mock_response = Mock()
mock_response.json.return_value = { mock_response.json.return_value = {

View File

@@ -129,10 +129,7 @@ class TestSettings:
security=SecurityConfig(secret_key="test"), security=SecurityConfig(secret_key="test"),
kc=KCConfig(client_secret="test"), kc=KCConfig(client_secret="test"),
) )
assert ( assert s.database_url == "postgresql+asyncpg://admin:secret@db.example.com:5433/mydb"
s.database_url
== "postgresql+asyncpg://admin:secret@db.example.com:5433/mydb"
)
def test_database_url_override(self) -> None: def test_database_url_override(self) -> None:
"""Test that explicit database URL overrides auto-building.""" """Test that explicit database URL overrides auto-building."""