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

@@ -2,7 +2,8 @@
Authentication utilities for testing.
This module provides tools to bypass FastAPI's authentication in tests.
"""
from typing import Callable, Dict, Optional
from collections.abc import Callable
from fastapi import FastAPI
from fastapi.security import OAuth2PasswordBearer
@@ -13,9 +14,9 @@ from app.models.user import User
def create_test_auth_client(
app: FastAPI,
test_user: User,
extra_overrides: Optional[Dict[Callable, Callable]] = None
app: FastAPI,
test_user: User,
extra_overrides: dict[Callable, Callable] | None = None,
) -> TestClient:
"""
Create a test client with authentication pre-configured.
@@ -47,10 +48,7 @@ def create_test_auth_client(
return TestClient(app)
def create_test_optional_auth_client(
app: FastAPI,
test_user: User
) -> TestClient:
def create_test_optional_auth_client(app: FastAPI, test_user: User) -> TestClient:
"""
Create a test client with optional authentication pre-configured.
@@ -70,10 +68,7 @@ def create_test_optional_auth_client(
return TestClient(app)
def create_test_superuser_client(
app: FastAPI,
test_user: User
) -> TestClient:
def create_test_superuser_client(app: FastAPI, test_user: User) -> TestClient:
"""
Create a test client with superuser authentication pre-configured.
@@ -120,7 +115,7 @@ def cleanup_test_client_auth(app: FastAPI) -> None:
auth_deps = [
get_current_user,
get_optional_current_user,
OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login"),
]
# Remove overrides

View File

@@ -1,8 +1,8 @@
"""
Utility functions for extracting and parsing device information from HTTP requests.
"""
import re
from typing import Optional
from fastapi import Request
@@ -19,11 +19,11 @@ def extract_device_info(request: Request) -> DeviceInfo:
Returns:
DeviceInfo object with parsed device information
"""
user_agent = request.headers.get('user-agent', '')
user_agent = request.headers.get("user-agent", "")
device_info = DeviceInfo(
device_name=parse_device_name(user_agent),
device_id=request.headers.get('x-device-id'), # Client must send this header
device_id=request.headers.get("x-device-id"), # Client must send this header
ip_address=get_client_ip(request),
user_agent=user_agent[:500] if user_agent else None, # Truncate to max length
location_city=None, # Can be populated via IP geolocation service
@@ -33,7 +33,7 @@ def extract_device_info(request: Request) -> DeviceInfo:
return device_info
def parse_device_name(user_agent: str) -> Optional[str]:
def parse_device_name(user_agent: str) -> str | None:
"""
Parse user agent string to extract a friendly device name.
@@ -54,48 +54,48 @@ def parse_device_name(user_agent: str) -> Optional[str]:
user_agent_lower = user_agent.lower()
# Mobile devices (check first, as they can contain desktop patterns too)
if 'iphone' in user_agent_lower:
if "iphone" in user_agent_lower:
return "iPhone"
elif 'ipad' in user_agent_lower:
elif "ipad" in user_agent_lower:
return "iPad"
elif 'android' in user_agent_lower:
elif "android" in user_agent_lower:
# Try to extract device model
android_match = re.search(r'android.*;\s*([^)]+)\s*build', user_agent_lower)
android_match = re.search(r"android.*;\s*([^)]+)\s*build", user_agent_lower)
if android_match:
device_model = android_match.group(1).strip()
return f"Android ({device_model.title()})"
return "Android device"
elif 'windows phone' in user_agent_lower:
elif "windows phone" in user_agent_lower:
return "Windows Phone"
# Tablets (check before desktop, as some tablets contain "android")
elif 'tablet' in user_agent_lower:
elif "tablet" in user_agent_lower:
return "Tablet"
# Smart TVs (check before desktop OS patterns)
elif any(tv in user_agent_lower for tv in ['smart-tv', 'smarttv']):
elif any(tv in user_agent_lower for tv in ["smart-tv", "smarttv"]):
return "Smart TV"
# Game consoles (check before desktop OS patterns, as Xbox contains "Windows")
elif 'playstation' in user_agent_lower:
elif "playstation" in user_agent_lower:
return "PlayStation"
elif 'xbox' in user_agent_lower:
elif "xbox" in user_agent_lower:
return "Xbox"
elif 'nintendo' in user_agent_lower:
elif "nintendo" in user_agent_lower:
return "Nintendo"
# Desktop operating systems
elif 'macintosh' in user_agent_lower or 'mac os x' in user_agent_lower:
elif "macintosh" in user_agent_lower or "mac os x" in user_agent_lower:
# Try to extract browser
browser = extract_browser(user_agent)
return f"{browser} on Mac" if browser else "Mac"
elif 'windows' in user_agent_lower:
elif "windows" in user_agent_lower:
browser = extract_browser(user_agent)
return f"{browser} on Windows" if browser else "Windows PC"
elif 'linux' in user_agent_lower and 'android' not in user_agent_lower:
elif "linux" in user_agent_lower and "android" not in user_agent_lower:
browser = extract_browser(user_agent)
return f"{browser} on Linux" if browser else "Linux"
elif 'cros' in user_agent_lower:
elif "cros" in user_agent_lower:
return "Chromebook"
# Fallback: just return browser name if detected
@@ -106,7 +106,7 @@ def parse_device_name(user_agent: str) -> Optional[str]:
return "Unknown device"
def extract_browser(user_agent: str) -> Optional[str]:
def extract_browser(user_agent: str) -> str | None:
"""
Extract browser name from user agent string.
@@ -126,26 +126,26 @@ def extract_browser(user_agent: str) -> Optional[str]:
user_agent_lower = user_agent.lower()
# Check specific browsers (order matters - check Edge before Chrome!)
if 'edg/' in user_agent_lower or 'edge/' in user_agent_lower:
if "edg/" in user_agent_lower or "edge/" in user_agent_lower:
return "Edge"
elif 'opr/' in user_agent_lower or 'opera' in user_agent_lower:
elif "opr/" in user_agent_lower or "opera" in user_agent_lower:
return "Opera"
elif 'chrome/' in user_agent_lower:
elif "chrome/" in user_agent_lower:
return "Chrome"
elif 'safari/' in user_agent_lower:
elif "safari/" in user_agent_lower:
# Make sure it's actually Safari, not Chrome (which also contains "Safari")
if 'chrome' not in user_agent_lower:
if "chrome" not in user_agent_lower:
return "Safari"
return None
elif 'firefox/' in user_agent_lower:
elif "firefox/" in user_agent_lower:
return "Firefox"
elif 'msie' in user_agent_lower or 'trident/' in user_agent_lower:
elif "msie" in user_agent_lower or "trident/" in user_agent_lower:
return "Internet Explorer"
return None
def get_client_ip(request: Request) -> Optional[str]:
def get_client_ip(request: Request) -> str | None:
"""
Extract client IP address from request, considering proxy headers.
@@ -163,14 +163,14 @@ def get_client_ip(request: Request) -> Optional[str]:
- request.client.host is fallback for direct connections
"""
# Check X-Forwarded-For (common in proxied environments)
x_forwarded_for = request.headers.get('x-forwarded-for')
x_forwarded_for = request.headers.get("x-forwarded-for")
if x_forwarded_for:
# Get the first IP (original client)
client_ip = x_forwarded_for.split(',')[0].strip()
client_ip = x_forwarded_for.split(",")[0].strip()
return client_ip
# Check X-Real-IP (used by some proxies like nginx)
x_real_ip = request.headers.get('x-real-ip')
x_real_ip = request.headers.get("x-real-ip")
if x_real_ip:
return x_real_ip.strip()
@@ -195,9 +195,17 @@ def is_mobile_device(user_agent: str) -> bool:
return False
mobile_patterns = [
'mobile', 'android', 'iphone', 'ipad', 'ipod',
'blackberry', 'windows phone', 'webos', 'opera mini',
'iemobile', 'mobile safari'
"mobile",
"android",
"iphone",
"ipad",
"ipod",
"blackberry",
"windows phone",
"webos",
"opera mini",
"iemobile",
"mobile safari",
]
user_agent_lower = user_agent.lower()
@@ -220,7 +228,7 @@ def get_device_type(user_agent: str) -> str:
user_agent_lower = user_agent.lower()
# Check for tablets first (they can contain "mobile" too)
if 'ipad' in user_agent_lower or 'tablet' in user_agent_lower:
if "ipad" in user_agent_lower or "tablet" in user_agent_lower:
return "tablet"
# Check for mobile
@@ -228,7 +236,7 @@ def get_device_type(user_agent: str) -> str:
return "mobile"
# Check for desktop OS patterns
if any(os in user_agent_lower for os in ['windows', 'macintosh', 'linux', 'cros']):
if any(os in user_agent_lower for os in ["windows", "macintosh", "linux", "cros"]):
return "desktop"
return "other"

View File

@@ -5,18 +5,21 @@ This module provides utilities for creating and verifying signed tokens,
useful for operations like file uploads, password resets, or any other
time-limited, single-use operations.
"""
import base64
import hashlib
import hmac
import json
import secrets
import time
from typing import Dict, Any, Optional
from typing import Any
from app.core.config import settings
def create_upload_token(file_path: str, content_type: str, expires_in: int = 300) -> str:
def create_upload_token(
file_path: str, content_type: str, expires_in: int = 300
) -> str:
"""
Create a signed token for secure file uploads.
@@ -40,34 +43,29 @@ def create_upload_token(file_path: str, content_type: str, expires_in: int = 300
"path": file_path,
"content_type": content_type,
"exp": int(time.time()) + expires_in,
"nonce": secrets.token_hex(8) # Add randomness to prevent token reuse
"nonce": secrets.token_hex(8), # Add randomness to prevent token reuse
}
# Convert to JSON and encode
payload_bytes = json.dumps(payload).encode('utf-8')
payload_bytes = json.dumps(payload).encode("utf-8")
# Create a signature using HMAC-SHA256 for security
# This prevents length extension attacks that plain SHA-256 is vulnerable to
signature = hmac.new(
settings.SECRET_KEY.encode('utf-8'),
payload_bytes,
hashlib.sha256
settings.SECRET_KEY.encode("utf-8"), payload_bytes, hashlib.sha256
).hexdigest()
# Combine payload and signature
token_data = {
"payload": payload,
"signature": signature
}
token_data = {"payload": payload, "signature": signature}
# Encode the final token
token_json = json.dumps(token_data)
token = base64.urlsafe_b64encode(token_json.encode('utf-8')).decode('utf-8')
token = base64.urlsafe_b64encode(token_json.encode("utf-8")).decode("utf-8")
return token
def verify_upload_token(token: str) -> Optional[Dict[str, Any]]:
def verify_upload_token(token: str) -> dict[str, Any] | None:
"""
Verify an upload token and return the payload if valid.
@@ -88,7 +86,7 @@ def verify_upload_token(token: str) -> Optional[Dict[str, Any]]:
"""
try:
# Decode the token
token_json = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8')
token_json = base64.urlsafe_b64decode(token.encode("utf-8")).decode("utf-8")
token_data = json.loads(token_json)
# Extract payload and signature
@@ -96,11 +94,9 @@ def verify_upload_token(token: str) -> Optional[Dict[str, Any]]:
signature = token_data["signature"]
# Verify signature using HMAC and constant-time comparison
payload_bytes = json.dumps(payload).encode('utf-8')
payload_bytes = json.dumps(payload).encode("utf-8")
expected_signature = hmac.new(
settings.SECRET_KEY.encode('utf-8'),
payload_bytes,
hashlib.sha256
settings.SECRET_KEY.encode("utf-8"), payload_bytes, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
@@ -136,34 +132,29 @@ def create_password_reset_token(email: str, expires_in: int = 3600) -> str:
"email": email,
"exp": int(time.time()) + expires_in,
"nonce": secrets.token_hex(16), # Extra randomness
"purpose": "password_reset"
"purpose": "password_reset",
}
# Convert to JSON and encode
payload_bytes = json.dumps(payload).encode('utf-8')
payload_bytes = json.dumps(payload).encode("utf-8")
# Create a signature using HMAC-SHA256 for security
# This prevents length extension attacks that plain SHA-256 is vulnerable to
signature = hmac.new(
settings.SECRET_KEY.encode('utf-8'),
payload_bytes,
hashlib.sha256
settings.SECRET_KEY.encode("utf-8"), payload_bytes, hashlib.sha256
).hexdigest()
# Combine payload and signature
token_data = {
"payload": payload,
"signature": signature
}
token_data = {"payload": payload, "signature": signature}
# Encode the final token
token_json = json.dumps(token_data)
token = base64.urlsafe_b64encode(token_json.encode('utf-8')).decode('utf-8')
token = base64.urlsafe_b64encode(token_json.encode("utf-8")).decode("utf-8")
return token
def verify_password_reset_token(token: str) -> Optional[str]:
def verify_password_reset_token(token: str) -> str | None:
"""
Verify a password reset token and return the email if valid.
@@ -182,7 +173,7 @@ def verify_password_reset_token(token: str) -> Optional[str]:
"""
try:
# Decode the token
token_json = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8')
token_json = base64.urlsafe_b64decode(token.encode("utf-8")).decode("utf-8")
token_data = json.loads(token_json)
# Extract payload and signature
@@ -194,11 +185,9 @@ def verify_password_reset_token(token: str) -> Optional[str]:
return None
# Verify signature using HMAC and constant-time comparison
payload_bytes = json.dumps(payload).encode('utf-8')
payload_bytes = json.dumps(payload).encode("utf-8")
expected_signature = hmac.new(
settings.SECRET_KEY.encode('utf-8'),
payload_bytes,
hashlib.sha256
settings.SECRET_KEY.encode("utf-8"), payload_bytes, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
@@ -234,34 +223,29 @@ def create_email_verification_token(email: str, expires_in: int = 86400) -> str:
"email": email,
"exp": int(time.time()) + expires_in,
"nonce": secrets.token_hex(16),
"purpose": "email_verification"
"purpose": "email_verification",
}
# Convert to JSON and encode
payload_bytes = json.dumps(payload).encode('utf-8')
payload_bytes = json.dumps(payload).encode("utf-8")
# Create a signature using HMAC-SHA256 for security
# This prevents length extension attacks that plain SHA-256 is vulnerable to
signature = hmac.new(
settings.SECRET_KEY.encode('utf-8'),
payload_bytes,
hashlib.sha256
settings.SECRET_KEY.encode("utf-8"), payload_bytes, hashlib.sha256
).hexdigest()
# Combine payload and signature
token_data = {
"payload": payload,
"signature": signature
}
token_data = {"payload": payload, "signature": signature}
# Encode the final token
token_json = json.dumps(token_data)
token = base64.urlsafe_b64encode(token_json.encode('utf-8')).decode('utf-8')
token = base64.urlsafe_b64encode(token_json.encode("utf-8")).decode("utf-8")
return token
def verify_email_verification_token(token: str) -> Optional[str]:
def verify_email_verification_token(token: str) -> str | None:
"""
Verify an email verification token and return the email if valid.
@@ -280,7 +264,7 @@ def verify_email_verification_token(token: str) -> Optional[str]:
"""
try:
# Decode the token
token_json = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8')
token_json = base64.urlsafe_b64decode(token.encode("utf-8")).decode("utf-8")
token_data = json.loads(token_json)
# Extract payload and signature
@@ -292,11 +276,9 @@ def verify_email_verification_token(token: str) -> Optional[str]:
return None
# Verify signature using HMAC and constant-time comparison
payload_bytes = json.dumps(payload).encode('utf-8')
payload_bytes = json.dumps(payload).encode("utf-8")
expected_signature = hmac.new(
settings.SECRET_KEY.encode('utf-8'),
payload_bytes,
hashlib.sha256
settings.SECRET_KEY.encode("utf-8"), payload_bytes, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):

View File

@@ -9,17 +9,19 @@ from app.core.database import Base
logger = logging.getLogger(__name__)
def get_test_engine():
"""Create an SQLite in-memory engine specifically for testing"""
test_engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool, # Use static pool for in-memory testing
echo=False
echo=False,
)
return test_engine
def setup_test_db():
"""Create a test database and session factory"""
# Create a new engine for this test run
@@ -30,14 +32,12 @@ def setup_test_db():
# Create session factory
TestingSessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=test_engine,
expire_on_commit=False
autocommit=False, autoflush=False, bind=test_engine, expire_on_commit=False
)
return test_engine, TestingSessionLocal
def teardown_test_db(engine):
"""Clean up after tests"""
# Drop all tables
@@ -46,13 +46,14 @@ def teardown_test_db(engine):
# Dispose of engine
engine.dispose()
async def get_async_test_engine():
"""Create an async SQLite in-memory engine specifically for testing"""
test_engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool, # Use static pool for in-memory testing
echo=False
echo=False,
)
return test_engine
@@ -69,7 +70,7 @@ async def setup_async_test_db():
autoflush=False,
bind=test_engine,
expire_on_commit=False,
class_=AsyncSession
class_=AsyncSession,
)
return test_engine, AsyncTestingSessionLocal