Add pyproject.toml for consolidated project configuration and replace Black, isort, and Flake8 with Ruff

- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest).
- Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting.
- Updated `requirements.txt` to include Ruff and remove replaced tools.
- Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
This commit is contained in:
2025-11-10 11:55:15 +01:00
parent a5c671c133
commit c589b565f0
86 changed files with 4572 additions and 3956 deletions

View File

@@ -1,39 +1,39 @@
import logging
logging.getLogger('passlib').setLevel(logging.ERROR)
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional, Union
import uuid
logging.getLogger("passlib").setLevel(logging.ERROR)
import asyncio
import uuid
from datetime import UTC, datetime, timedelta
from functools import partial
from typing import Any
from jose import jwt, JWTError
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import ValidationError
from app.core.config import settings
from app.schemas.users import TokenData, TokenPayload
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Custom exceptions for auth
class AuthError(Exception):
"""Base authentication error"""
pass
class TokenExpiredError(AuthError):
"""Token has expired"""
pass
class TokenInvalidError(AuthError):
"""Token is invalid"""
pass
class TokenMissingClaimError(AuthError):
"""Token is missing a required claim"""
pass
def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -62,8 +62,7 @@ async def verify_password_async(plain_password: str, hashed_password: str) -> bo
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
partial(pwd_context.verify, plain_password, hashed_password)
None, partial(pwd_context.verify, plain_password, hashed_password)
)
@@ -82,17 +81,13 @@ async def get_password_hash_async(password: str) -> str:
Hashed password string
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
pwd_context.hash,
password
)
return await loop.run_in_executor(None, pwd_context.hash, password)
def create_access_token(
subject: Union[str, Any],
expires_delta: Optional[timedelta] = None,
claims: Optional[Dict[str, Any]] = None
subject: str | Any,
expires_delta: timedelta | None = None,
claims: dict[str, Any] | None = None,
) -> str:
"""
Create a JWT access token.
@@ -106,17 +101,19 @@ def create_access_token(
Encoded JWT token
"""
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
expire = datetime.now(UTC) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(UTC) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
# Base token data
to_encode = {
"sub": str(subject),
"exp": expire,
"iat": datetime.now(tz=timezone.utc),
"iat": datetime.now(tz=UTC),
"jti": str(uuid.uuid4()),
"type": "access"
"type": "access",
}
# Add custom claims
@@ -125,17 +122,14 @@ def create_access_token(
# Create the JWT
encoded_jwt = jwt.encode(
to_encode,
settings.SECRET_KEY,
algorithm=settings.ALGORITHM
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def create_refresh_token(
subject: Union[str, Any],
expires_delta: Optional[timedelta] = None
subject: str | Any, expires_delta: timedelta | None = None
) -> str:
"""
Create a JWT refresh token.
@@ -148,28 +142,26 @@ def create_refresh_token(
Encoded JWT refresh token
"""
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
expire = datetime.now(UTC) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
expire = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {
"sub": str(subject),
"exp": expire,
"iat": datetime.now(timezone.utc),
"iat": datetime.now(UTC),
"jti": str(uuid.uuid4()),
"type": "refresh"
"type": "refresh",
}
encoded_jwt = jwt.encode(
to_encode,
settings.SECRET_KEY,
algorithm=settings.ALGORITHM
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def decode_token(token: str, verify_type: Optional[str] = None) -> TokenPayload:
def decode_token(token: str, verify_type: str | None = None) -> TokenPayload:
"""
Decode and verify a JWT token.
@@ -195,8 +187,8 @@ def decode_token(token: str, verify_type: Optional[str] = None) -> TokenPayload:
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
"require": ["exp", "sub", "iat"]
}
"require": ["exp", "sub", "iat"],
},
)
# SECURITY: Explicitly verify the algorithm to prevent algorithm confusion attacks
@@ -250,4 +242,4 @@ def get_token_data(token: str) -> TokenData:
user_id = payload.sub
is_superuser = payload.is_superuser or False
return TokenData(user_id=uuid.UUID(user_id), is_superuser=is_superuser)
return TokenData(user_id=uuid.UUID(user_id), is_superuser=is_superuser)

View File

@@ -1,5 +1,4 @@
import logging
from typing import Optional, List
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
@@ -13,7 +12,7 @@ class Settings(BaseSettings):
# Environment (must be before SECRET_KEY for validation)
ENVIRONMENT: str = Field(
default="development",
description="Environment: development, staging, or production"
description="Environment: development, staging, or production",
)
# Security: Content Security Policy
@@ -21,8 +20,7 @@ class Settings(BaseSettings):
# Set to True for strict CSP (blocks most external resources)
# Set to "relaxed" for modern frontend development
CSP_MODE: str = Field(
default="relaxed",
description="CSP mode: 'strict', 'relaxed', or 'disabled'"
default="relaxed", description="CSP mode: 'strict', 'relaxed', or 'disabled'"
)
# Database configuration
@@ -31,7 +29,7 @@ class Settings(BaseSettings):
POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: str = "5432"
POSTGRES_DB: str = "app"
DATABASE_URL: Optional[str] = None
DATABASE_URL: str | None = None
db_pool_size: int = 20 # Default connection pool size
db_max_overflow: int = 50 # Maximum overflow connections
db_pool_timeout: int = 30 # Seconds to wait for a connection
@@ -59,38 +57,36 @@ class Settings(BaseSettings):
SECRET_KEY: str = Field(
default="dev_only_insecure_key_change_in_production_32chars_min",
min_length=32,
description="JWT signing key. MUST be changed in production. Generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'"
description="JWT signing key. MUST be changed in production. Generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'",
)
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 # 15 minutes (production standard)
REFRESH_TOKEN_EXPIRE_DAYS: int = 7 # 7 days
# CORS configuration
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000"]
BACKEND_CORS_ORIGINS: list[str] = ["http://localhost:3000"]
# Frontend URL for email links
FRONTEND_URL: str = Field(
default="http://localhost:3000",
description="Frontend application URL for email links"
description="Frontend application URL for email links",
)
# Admin user
FIRST_SUPERUSER_EMAIL: Optional[str] = Field(
default=None,
description="Email for first superuser account"
FIRST_SUPERUSER_EMAIL: str | None = Field(
default=None, description="Email for first superuser account"
)
FIRST_SUPERUSER_PASSWORD: Optional[str] = Field(
default=None,
description="Password for first superuser (min 12 characters)"
FIRST_SUPERUSER_PASSWORD: str | None = Field(
default=None, description="Password for first superuser (min 12 characters)"
)
@field_validator('SECRET_KEY')
@field_validator("SECRET_KEY")
@classmethod
def validate_secret_key(cls, v: str, info) -> str:
"""Validate SECRET_KEY is secure, especially in production."""
# Get environment from values if available
values_data = info.data if info.data else {}
env = values_data.get('ENVIRONMENT', 'development')
env = values_data.get("ENVIRONMENT", "development")
if v.startswith("your_secret_key_here"):
if env == "production":
@@ -106,13 +102,15 @@ class Settings(BaseSettings):
)
if len(v) < 32:
raise ValueError("SECRET_KEY must be at least 32 characters long for security")
raise ValueError(
"SECRET_KEY must be at least 32 characters long for security"
)
return v
@field_validator('FIRST_SUPERUSER_PASSWORD')
@field_validator("FIRST_SUPERUSER_PASSWORD")
@classmethod
def validate_superuser_password(cls, v: Optional[str]) -> Optional[str]:
def validate_superuser_password(cls, v: str | None) -> str | None:
"""Validate superuser password strength."""
if v is None:
return v
@@ -121,7 +119,13 @@ class Settings(BaseSettings):
raise ValueError("FIRST_SUPERUSER_PASSWORD must be at least 12 characters")
# Check for common weak passwords
weak_passwords = {'admin123', 'Admin123', 'password123', 'Password123', '123456789012'}
weak_passwords = {
"admin123",
"Admin123",
"password123",
"Password123",
"123456789012",
}
if v in weak_passwords:
raise ValueError(
"FIRST_SUPERUSER_PASSWORD is too weak. "
@@ -144,8 +148,8 @@ class Settings(BaseSettings):
"env_file": "../.env",
"env_file_encoding": "utf-8",
"case_sensitive": True,
"extra": "ignore" # Ignore extra fields from .env (e.g., frontend-specific vars)
"extra": "ignore", # Ignore extra fields from .env (e.g., frontend-specific vars)
}
settings = Settings()
settings = Settings()

View File

@@ -5,17 +5,18 @@ Database configuration using SQLAlchemy 2.0 and asyncpg.
This module provides async database connectivity with proper connection pooling
and session management for FastAPI endpoints.
"""
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from sqlalchemy import text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.ext.asyncio import (
AsyncSession,
AsyncEngine,
create_async_engine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import DeclarativeBase
@@ -27,12 +28,12 @@ logger = logging.getLogger(__name__)
# SQLite compatibility for testing
@compiles(JSONB, 'sqlite')
@compiles(JSONB, "sqlite")
def compile_jsonb_sqlite(type_, compiler, **kw):
return "TEXT"
@compiles(UUID, 'sqlite')
@compiles(UUID, "sqlite")
def compile_uuid_sqlite(type_, compiler, **kw):
return "TEXT"
@@ -40,7 +41,6 @@ def compile_uuid_sqlite(type_, compiler, **kw):
# Declarative base for models (SQLAlchemy 2.0 style)
class Base(DeclarativeBase):
"""Base class for all database models."""
pass
def get_async_database_url(url: str) -> str:
@@ -139,7 +139,7 @@ async def async_transaction_scope() -> AsyncGenerator[AsyncSession, None]:
logger.debug("Async transaction committed successfully")
except Exception as e:
await session.rollback()
logger.error(f"Async transaction failed, rolling back: {str(e)}")
logger.error(f"Async transaction failed, rolling back: {e!s}")
raise
finally:
await session.close()
@@ -155,7 +155,7 @@ async def check_async_database_health() -> bool:
await db.execute(text("SELECT 1"))
return True
except Exception as e:
logger.error(f"Async database health check failed: {str(e)}")
logger.error(f"Async database health check failed: {e!s}")
return False

View File

@@ -1,8 +1,8 @@
"""
Custom exceptions and global exception handlers for the API.
"""
import logging
from typing import Optional, Union
from fastapi import HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
@@ -27,17 +27,13 @@ class APIException(HTTPException):
status_code: int,
error_code: ErrorCode,
message: str,
field: Optional[str] = None,
headers: Optional[dict] = None
field: str | None = None,
headers: dict | None = None,
):
self.error_code = error_code
self.field = field
self.message = message
super().__init__(
status_code=status_code,
detail=message,
headers=headers
)
super().__init__(status_code=status_code, detail=message, headers=headers)
class AuthenticationError(APIException):
@@ -47,14 +43,14 @@ class AuthenticationError(APIException):
self,
message: str = "Authentication failed",
error_code: ErrorCode = ErrorCode.INVALID_CREDENTIALS,
field: Optional[str] = None
field: str | None = None,
):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
error_code=error_code,
message=message,
field=field,
headers={"WWW-Authenticate": "Bearer"}
headers={"WWW-Authenticate": "Bearer"},
)
@@ -64,12 +60,12 @@ class AuthorizationError(APIException):
def __init__(
self,
message: str = "Insufficient permissions",
error_code: ErrorCode = ErrorCode.INSUFFICIENT_PERMISSIONS
error_code: ErrorCode = ErrorCode.INSUFFICIENT_PERMISSIONS,
):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
error_code=error_code,
message=message
message=message,
)
@@ -79,12 +75,12 @@ class NotFoundError(APIException):
def __init__(
self,
message: str = "Resource not found",
error_code: ErrorCode = ErrorCode.NOT_FOUND
error_code: ErrorCode = ErrorCode.NOT_FOUND,
):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
error_code=error_code,
message=message
message=message,
)
@@ -95,13 +91,13 @@ class DuplicateError(APIException):
self,
message: str = "Resource already exists",
error_code: ErrorCode = ErrorCode.DUPLICATE_ENTRY,
field: Optional[str] = None
field: str | None = None,
):
super().__init__(
status_code=status.HTTP_409_CONFLICT,
error_code=error_code,
message=message,
field=field
field=field,
)
@@ -112,13 +108,13 @@ class ValidationException(APIException):
self,
message: str = "Validation error",
error_code: ErrorCode = ErrorCode.VALIDATION_ERROR,
field: Optional[str] = None
field: str | None = None,
):
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
error_code=error_code,
message=message,
field=field
field=field,
)
@@ -128,12 +124,12 @@ class DatabaseError(APIException):
def __init__(
self,
message: str = "Database operation failed",
error_code: ErrorCode = ErrorCode.DATABASE_ERROR
error_code: ErrorCode = ErrorCode.DATABASE_ERROR,
):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
error_code=error_code,
message=message
message=message,
)
@@ -152,23 +148,18 @@ async def api_exception_handler(request: Request, exc: APIException) -> JSONResp
)
error_response = ErrorResponse(
errors=[ErrorDetail(
code=exc.error_code,
message=exc.message,
field=exc.field
)]
errors=[ErrorDetail(code=exc.error_code, message=exc.message, field=exc.field)]
)
return JSONResponse(
status_code=exc.status_code,
content=error_response.model_dump(),
headers=exc.headers
headers=exc.headers,
)
async def validation_exception_handler(
request: Request,
exc: Union[RequestValidationError, ValidationError]
request: Request, exc: RequestValidationError | ValidationError
) -> JSONResponse:
"""
Handler for Pydantic validation errors.
@@ -189,22 +180,19 @@ async def validation_exception_handler(
# Skip 'body' or 'query' prefix in location
field = ".".join(str(x) for x in error["loc"][1:])
errors.append(ErrorDetail(
code=ErrorCode.VALIDATION_ERROR,
message=error["msg"],
field=field
))
errors.append(
ErrorDetail(
code=ErrorCode.VALIDATION_ERROR, message=error["msg"], field=field
)
)
logger.warning(
f"Validation error: {len(errors)} errors "
f"(path: {request.url.path})"
)
logger.warning(f"Validation error: {len(errors)} errors (path: {request.url.path})")
error_response = ErrorResponse(errors=errors)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=error_response.model_dump()
content=error_response.model_dump(),
)
@@ -226,26 +214,21 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
}
error_code = status_code_to_error_code.get(
exc.status_code,
ErrorCode.INTERNAL_ERROR
exc.status_code, ErrorCode.INTERNAL_ERROR
)
logger.warning(
f"HTTP exception: {exc.status_code} - {exc.detail} "
f"(path: {request.url.path})"
f"HTTP exception: {exc.status_code} - {exc.detail} (path: {request.url.path})"
)
error_response = ErrorResponse(
errors=[ErrorDetail(
code=error_code,
message=str(exc.detail)
)]
errors=[ErrorDetail(code=error_code, message=str(exc.detail))]
)
return JSONResponse(
status_code=exc.status_code,
content=error_response.model_dump(),
headers=exc.headers
headers=exc.headers,
)
@@ -257,26 +240,24 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR
leaking sensitive information in production.
"""
logger.error(
f"Unhandled exception: {type(exc).__name__} - {str(exc)} "
f"Unhandled exception: {type(exc).__name__} - {exc!s} "
f"(path: {request.url.path})",
exc_info=True
exc_info=True,
)
# In production, don't expose internal error details
from app.core.config import settings
if settings.ENVIRONMENT == "production":
message = "An internal error occurred. Please try again later."
else:
message = f"{type(exc).__name__}: {str(exc)}"
message = f"{type(exc).__name__}: {exc!s}"
error_response = ErrorResponse(
errors=[ErrorDetail(
code=ErrorCode.INTERNAL_ERROR,
message=message
)]
errors=[ErrorDetail(code=ErrorCode.INTERNAL_ERROR, message=message)]
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=error_response.model_dump()
content=error_response.model_dump(),
)