diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 369d3e1b..8e73c5a8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,7 @@ version: 2 updates: # Python dependencies - - package-ecosystem: "pip" + - package-ecosystem: "uv" directory: "/" schedule: interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7b6cd4a..4d080051 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,20 +14,24 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - name: Install uv and Python + uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 with: - python-version: "3.10" - cache: "pip" + version: "0.11.3" + python-version: "3.14" + enable-cache: true - - name: Install dependencies - run: | - pip install -r requirements-base.txt - pip install -r requirements-shelfmark.txt - pip install pytest + - name: Sync dependencies + run: uv sync --locked --extra browser + + - name: Lint backend + run: uv run ruff check shelfmark + + - name: Check backend formatting + run: uv run ruff format --check shelfmark - name: Run tests - run: pytest tests/ -x --tb=short + run: uv run pytest tests/ -x --tb=short frontend-checks: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index 08fc0029..8b96ce08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,8 @@ RUN npm run build # Use python-slim as the base image FROM python:3.14-slim AS base +COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /uvx /bin/ + # Add build argument for version ARG BUILD_VERSION ENV BUILD_VERSION=${BUILD_VERSION} @@ -39,13 +41,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Consistent environment variables grouped together ENV DEBIAN_FRONTEND=noninteractive \ DOCKERMODE=true \ + UV_LINK_MODE=copy \ PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONIOENCODING=UTF-8 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 \ - PIP_DEFAULT_TIMEOUT=100 \ NAME=Shelfmark \ + PATH=/app/.venv/bin:$PATH \ PYTHONPATH=/app \ # PUID/PGID will be handled by entrypoint script, but TZ/Locale are still needed LANG=en_US.UTF-8 \ @@ -91,12 +92,10 @@ RUN apt-get update && \ # Set working directory WORKDIR /app -# Install Python dependencies using pip -# Copying requirements files separately leverages build cache -# Cache mount persists pip cache between builds for faster installs -COPY requirements-base.txt requirements-shelfmark.txt ./ -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install -r requirements-base.txt +# Install core Python dependencies first for better layer caching +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-default-groups # Copy application code *after* dependencies are installed COPY . . @@ -146,9 +145,9 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -# Install additional dependencies (requirements file already copied in base stage) -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install -r requirements-shelfmark.txt +# Install the browser automation stack used by the full image +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-default-groups --extra browser # Grant read/execute permissions to others RUN chmod -R o+rx /usr/bin/chromium diff --git a/Makefile b/Makefile index a2fda70c..866ca122 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install dev build preview typecheck frontend-test clean up down docker-build refresh restart build-serve +.PHONY: help install install-python-dev dev build preview typecheck frontend-test clean up up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-check # Frontend directory FRONTEND_DIR := src/frontend @@ -18,6 +18,11 @@ help: @echo " preview - Preview production build" @echo " typecheck - Run TypeScript type checking" @echo " frontend-test - Run frontend unit tests" + @echo " install-python-dev - Sync Python runtime + dev tooling with uv" + @echo " python-lint - Run Ruff against Python backend code" + @echo " python-lint-fix - Run Ruff with safe auto-fixes" + @echo " python-format - Format Python backend code with Ruff" + @echo " python-format-check - Check Python backend formatting with Ruff" @echo " clean - Remove node_modules and build artifacts" @echo "" @echo "Backend (Docker):" @@ -32,6 +37,11 @@ install: @echo "Installing frontend dependencies..." cd $(FRONTEND_DIR) && npm install +# Install Python development dependencies +install-python-dev: + @echo "Syncing Python runtime and dev tooling with uv..." + uv sync --locked --extra browser + # Start development server dev: @echo "Starting development server..." @@ -59,6 +69,23 @@ typecheck: @echo "Running TypeScript type checking..." cd $(FRONTEND_DIR) && npm run typecheck +# Python linting +python-lint: + @echo "Running Ruff..." + uv run ruff check shelfmark + +python-lint-fix: + @echo "Running Ruff with safe auto-fixes..." + uv run ruff check shelfmark --fix + +python-format: + @echo "Formatting Python backend code with Ruff..." + uv run ruff format shelfmark + +python-format-check: + @echo "Checking Python backend formatting with Ruff..." + uv run ruff format --check shelfmark + # Run frontend unit tests frontend-test: @echo "Running frontend unit tests..." diff --git a/docker-compose.test-clients.yml b/docker-compose.test-clients.yml index f63411d0..6a1f50f7 100644 --- a/docker-compose.test-clients.yml +++ b/docker-compose.test-clients.yml @@ -54,6 +54,7 @@ services: # Mount tests for running pytest in container - ./tests:/app/tests:ro - ./pyproject.toml:/app/pyproject.toml:ro + - ./uv.lock:/app/uv.lock:ro # Mount client configs for integration tests to read credentials - ./.local/test-clients/qbittorrent/config:/qbittorrent-config:ro - ./.local/test-clients/sabnzbd/config:/sabnzbd-config:ro diff --git a/entrypoint.sh b/entrypoint.sh index dc978d6d..4fa616aa 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -68,6 +68,11 @@ else fi set -e +PYTHON_BIN="/app/.venv/bin/python" +if [ ! -x "$PYTHON_BIN" ]; then + PYTHON_BIN="python3" +fi + # Print build version echo "Build version: $BUILD_VERSION" echo "Release version: $RELEASE_VERSION" @@ -294,7 +299,7 @@ if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then # Keep SeleniumBase's bundled drivers directory writable as well for # compatibility with legacy UC code paths that still probe bundled assets. set +e - SELENIUMBASE_DRIVERS_DIR=$(python3 -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null) + SELENIUMBASE_DRIVERS_DIR=$("$PYTHON_BIN" -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null) set -e if [ -n "$SELENIUMBASE_DRIVERS_DIR" ] && [ -d "$SELENIUMBASE_DRIVERS_DIR" ]; then @@ -317,7 +322,7 @@ make_writable "${INGEST_DIR:-/books}" root # Check any additional configured destination roots from saved settings echo "Checking for additional configured destination roots..." if [ -f /app/scripts/fix_permissions.py ]; then - configured_dirs=$(python3 /app/scripts/fix_permissions.py 2>/dev/null || echo "") + configured_dirs=$("$PYTHON_BIN" /app/scripts/fix_permissions.py 2>/dev/null || echo "") if [ -n "$configured_dirs" ]; then echo "$configured_dirs" | while read -r dir; do if [ -n "$dir" ] && [ -d "$dir" ]; then @@ -372,7 +377,7 @@ if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then set -x echo "vvvvvvvvvvvv DEBUG MODE vvvvvvvvvvvv" echo "Starting Xvfb for debugging" - python3 -c "from pyvirtualdisplay import Display; Display(visible=False, size=(1440,1880)).start()" + "$PYTHON_BIN" -c "from pyvirtualdisplay import Display; Display(visible=False, size=(1440,1880)).start()" id free -h uname -a diff --git a/pyproject.toml b/pyproject.toml index 7856c9cc..8b24bf2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,44 @@ name = "shelfmark" version = "0.1.0" description = "Shelfmark - Book Downloader" -requires-python = ">=3.10" +requires-python = ">=3.14" +dependencies = [ + "flask", + "flask-cors", + "flask-socketio", + "python-socketio", + "requests[socks]", + "defusedxml", + "beautifulsoup4", + "tqdm", + "dnspython", + "gunicorn", + "gevent", + "gevent-websocket", + "psutil", + "emoji", + "rarfile", + "qbittorrent-api", + "transmission-rpc", + "authlib>=1.6.6,<1.7", + "apprise>=1.9.0", +] + +[project.optional-dependencies] +browser = [ + "pyvirtualdisplay", + "pyautogui", + "seleniumbase==4.47.9", + "python-xlib", +] + +[dependency-groups] +dev = [ + "pyright>=1.1.408", + "pytest", + "pytest-xdist>=3.8.0", + "ruff==0.15.9", +] [tool.pytest.ini_options] testpaths = ["tests"] @@ -12,6 +49,8 @@ python_functions = ["test_*"] addopts = [ "-v", "--tb=short", + "-n", + "auto", ] markers = [ "integration: marks tests that require running services (deselect with '-m \"not integration\"')", @@ -19,8 +58,50 @@ markers = [ "e2e: marks end-to-end tests that require the full application stack", ] -[tool.mypy] -python_version = "3.10" -warn_return_any = true -warn_unused_ignores = true -ignore_missing_imports = true +[tool.ruff] +line-length = 100 +extend-exclude = [".local"] + +[tool.ruff.lint] +select = [ + "F", "I", "UP", "B", "C4", "SIM", "PTH", "RET", "PIE", "FURB", "PERF", "TRY", + "ANN001", "ANN201", "ANN202", "ANN204", + "E731", + "FBT002", "FBT003", + "G003", "G004", + "PLR1714", + "PLW0108", + "Q000", + "RUF005", "RUF012", "RUF013", "RUF059", "RUF100", + "TC001", "TC003", +] +ignore = ["UP035", "TRY003", "E501"] + +[tool.pyright] +include = ["shelfmark"] +exclude = [".local", "tests", "**/__pycache__", "**/node_modules"] +pythonVersion = "3.14" +typeCheckingMode = "off" + +[tool.vulture] +paths = ["shelfmark"] +exclude = [".local", "tests"] +ignore_decorators = [ + "@app.route", + "@app.before_request", + "@app.after_request", + "@app.errorhandler", + "@socketio.on", + "@register_provider", + "@register_provider_kwargs", + "@register_settings", + "@register_source", + "@register_handler", + "@register_client", + "@register_output", +] +min_confidence = 90 +sort_by_size = true + +[tool.uv] +package = false diff --git a/requirements-base.txt b/requirements-base.txt deleted file mode 100644 index d1edf950..00000000 --- a/requirements-base.txt +++ /dev/null @@ -1,19 +0,0 @@ -flask -flask-cors -flask-socketio -python-socketio -requests[socks] -defusedxml -beautifulsoup4 -tqdm -dnspython -gunicorn -gevent -gevent-websocket -psutil -emoji -rarfile -qbittorrent-api -transmission-rpc -authlib>=1.6.6,<1.7 -apprise>=1.9.0 diff --git a/requirements-shelfmark.txt b/requirements-shelfmark.txt deleted file mode 100644 index bf161431..00000000 --- a/requirements-shelfmark.txt +++ /dev/null @@ -1,4 +0,0 @@ -pyvirtualdisplay -pyautogui -seleniumbase==4.47.9 -python-xlib diff --git a/scripts/test_clients.py b/scripts/test_clients.py index e734e874..55bb0f5a 100755 --- a/scripts/test_clients.py +++ b/scripts/test_clients.py @@ -9,7 +9,7 @@ Usage: 2. Wait for containers to initialize (first run takes ~30s) 3. Run this script to verify clients are accessible: - python scripts/test_clients.py + uv run python scripts/test_clients.py 4. Access cwabd at http://localhost:8084 - Go to Settings > Prowlarr > Download Clients @@ -26,7 +26,7 @@ Web UIs: - rTorrent: http://localhost:8000 (web ui http://localhost:8089 via ruTorrent) Prerequisites (for running this script locally): - pip install requests transmission-rpc qbittorrent-api + uv sync --locked First-Time Setup: qBittorrent: @@ -260,7 +260,7 @@ def test_qbittorrent(): except ImportError: print(" ERROR: qbittorrent-api not installed") - print(" Run: pip install qbittorrent-api") + print(" Run: uv sync --locked") return False except Exception as e: print(f" ERROR: {e}") @@ -317,7 +317,7 @@ def test_transmission(): except ImportError: print(" ERROR: transmission-rpc not installed") - print(" Run: pip install transmission-rpc") + print(" Run: uv sync --locked") return False except Exception as e: print(f" ERROR: {e}") diff --git a/shelfmark/__main__.py b/shelfmark/__main__.py index 2ab2619a..1cc86880 100644 --- a/shelfmark/__main__.py +++ b/shelfmark/__main__.py @@ -1,8 +1,8 @@ """Package entry point for `python -m shelfmark`.""" -from shelfmark.main import app, socketio from shelfmark.config.env import FLASK_HOST, FLASK_PORT from shelfmark.core.config import config +from shelfmark.main import app, socketio if __name__ == "__main__": socketio.run(app, host=FLASK_HOST, port=FLASK_PORT, debug=config.get("DEBUG", False)) diff --git a/shelfmark/api/websocket.py b/shelfmark/api/websocket.py index 0ce655e5..75b382f1 100644 --- a/shelfmark/api/websocket.py +++ b/shelfmark/api/websocket.py @@ -2,123 +2,71 @@ import logging import threading -from typing import Optional, Dict, Any, Callable, List +from typing import TYPE_CHECKING, Any +from flask import Flask from flask_socketio import SocketIO, join_room, leave_room +if TYPE_CHECKING: + from collections.abc import Callable + logger = logging.getLogger(__name__) class WebSocketManager: """Manages WebSocket connections and broadcasts.""" - def __init__(self): - self.socketio: Optional[SocketIO] = None + def __init__(self) -> None: + self.socketio: SocketIO | None = None self._enabled = False self._connection_count = 0 self._connection_lock = threading.Lock() - self._on_first_connect_callbacks: List[Callable[[], None]] = [] - self._on_all_disconnect_callbacks: List[Callable[[], None]] = [] - self._needs_rewarm = False # Flag to trigger warmup callbacks on next connect - self._user_rooms: Dict[str, int] = {} # room_name -> ref count - self._sid_rooms: Dict[str, str] = {} # sid -> room_name + self._user_rooms: dict[str, int] = {} # room_name -> ref count + self._sid_rooms: dict[str, str] = {} # sid -> room_name self._rooms_lock = threading.Lock() - self._queue_status_fn: Optional[Callable] = None # Reference to queue_status() + self._queue_status_fn: Callable | None = None # Reference to queue_status() - def init_app(self, app, socketio: SocketIO): + def init_app(self, app: Flask, socketio: SocketIO) -> None: """Initialize the WebSocket manager with Flask-SocketIO instance.""" self.socketio = socketio self._enabled = True logger.info("WebSocket manager initialized") - def register_on_first_connect(self, callback: Callable[[], None]): - """Register a callback for when the first client connects.""" - self._on_first_connect_callbacks.append(callback) - logger.debug(f"Registered on_first_connect callback: {callback.__name__}") - - def register_on_all_disconnect(self, callback: Callable[[], None]): - """Register a callback for when all clients disconnect.""" - self._on_all_disconnect_callbacks.append(callback) - logger.debug(f"Registered on_all_disconnect callback: {callback.__name__}") - - def request_warmup_on_next_connect(self): - """Request warmup callbacks on the next client connect (e.g., after idle shutdown).""" - with self._connection_lock: - self._needs_rewarm = True - logger.debug("Warmup requested for next client connect") - - def client_connected(self): + def client_connected(self) -> None: """Track a new client connection. Call this from the connect event handler.""" with self._connection_lock: - was_zero = self._connection_count == 0 - needs_rewarm = self._needs_rewarm self._connection_count += 1 current_count = self._connection_count - # Clear rewarm flag if we're going to trigger warmup - if was_zero or needs_rewarm: - self._needs_rewarm = False - logger.debug(f"Client connected. Active connections: {current_count}") + logger.debug("Client connected. Active connections: %s", current_count) - # Trigger warmup callbacks if this is the first connection OR if rewarm was requested - # (rewarm is requested when bypasser shuts down due to idle while clients are connected) - if was_zero or needs_rewarm: - reason = "First client connected" if was_zero else "Rewarm requested after idle shutdown" - logger.info(f"{reason}, triggering warmup callbacks...") - for callback in self._on_first_connect_callbacks: - try: - # Run callbacks in a separate thread to not block the connection - thread = threading.Thread(target=callback, daemon=True) - thread.start() - except Exception as e: - logger.error(f"Error in on_first_connect callback {callback.__name__}: {e}") - - def client_disconnected(self): + def client_disconnected(self) -> None: """Track a client disconnection. Call this from the disconnect event handler.""" with self._connection_lock: self._connection_count = max(0, self._connection_count - 1) current_count = self._connection_count - is_now_zero = current_count == 0 - logger.debug(f"Client disconnected. Active connections: {current_count}") - - # If all clients have disconnected, trigger cleanup callbacks - if is_now_zero: - logger.info("All clients disconnected, triggering disconnect callbacks...") - for callback in self._on_all_disconnect_callbacks: - try: - callback() - except Exception as e: - logger.error(f"Error in on_all_disconnect callback {callback.__name__}: {e}") - - def get_connection_count(self) -> int: - """Get the current number of active WebSocket connections.""" - with self._connection_lock: - return self._connection_count - - def has_active_connections(self) -> bool: - """Check if there are any active WebSocket connections.""" - return self.get_connection_count() > 0 + logger.debug("Client disconnected. Active connections: %s", current_count) def is_enabled(self) -> bool: """Check if WebSocket is enabled and ready.""" return self._enabled and self.socketio is not None - def set_queue_status_fn(self, fn: Callable): + def set_queue_status_fn(self, fn: Callable) -> None: """Set the queue_status function reference for per-room filtering.""" self._queue_status_fn = fn - def _increment_user_room_locked(self, room: str): + def _increment_user_room_locked(self, room: str) -> None: self._user_rooms[room] = self._user_rooms.get(room, 0) + 1 - def _decrement_user_room_locked(self, room: str): + def _decrement_user_room_locked(self, room: str) -> None: count = self._user_rooms.get(room, 1) - 1 if count <= 0: self._user_rooms.pop(room, None) else: self._user_rooms[room] = count - def _set_sid_room_locked(self, sid: str, room: Optional[str]): + def _set_sid_room_locked(self, sid: str, room: str | None) -> None: current_room = self._sid_rooms.get(sid) if current_room == room: return @@ -135,9 +83,14 @@ class WebSocketManager: if room.startswith("user_"): self._increment_user_room_locked(room) - def sync_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None): + def sync_user_room( + self, + sid: str, + is_admin: bool, + db_user_id: int | None = None, + ) -> None: """Ensure a SID is in exactly one room matching the current session scope.""" - room: Optional[str] = None + room: str | None = None if is_admin: room = "admins" elif db_user_id is not None: @@ -146,24 +99,35 @@ class WebSocketManager: with self._rooms_lock: self._set_sid_room_locked(sid, room) - def join_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None): + def join_user_room( + self, + sid: str, + is_admin: bool, + db_user_id: int | None = None, + ) -> None: """Join the appropriate room based on user role.""" - self.sync_user_room(sid, is_admin, db_user_id) + self.sync_user_room(sid, is_admin=is_admin, db_user_id=db_user_id) - def leave_user_room(self, sid: str, is_admin: bool = False, db_user_id: Optional[int] = None): + def leave_user_room( + self, + sid: str, + *, + is_admin: bool = False, + db_user_id: int | None = None, + ) -> None: """Leave whichever room the SID currently belongs to.""" del is_admin, db_user_id # Backward-compatible signature; routing is SID-based. with self._rooms_lock: self._set_sid_room_locked(sid, None) - def broadcast_status_update(self, status_data: Dict[str, Any]): + def broadcast_status_update(self, status_data: dict[str, Any]) -> None: """Broadcast status update to all connected clients, filtered by user room.""" if not self.is_enabled(): return try: # Admins (and no-auth users) get full status - self.socketio.emit('status_update', status_data, to="admins") + self.socketio.emit("status_update", status_data, to="admins") # Each user room gets filtered status with self._rooms_lock: @@ -171,56 +135,43 @@ class WebSocketManager: if active_rooms and self._queue_status_fn: for room in active_rooms: - try: - # Extract user_id from room name "user_123" - uid = int(room.split("_", 1)[1]) - filtered = self._queue_status_fn(user_id=uid) - self.socketio.emit('status_update', filtered, to=room) - except Exception as e: - logger.error(f"Failed to send status update for room {room}: {e}") + self._broadcast_status_update_to_room(room) logger.debug("Broadcasted status update to all rooms") - except Exception as e: - logger.error(f"Error broadcasting status update: {e}") + except Exception: + logger.exception("Error broadcasting status update") - def broadcast_download_progress(self, book_id: str, progress: float, status: str, user_id: Optional[int] = None): + def _broadcast_status_update_to_room(self, room: str) -> None: + """Broadcast status update to one user room.""" + try: + # Extract user_id from room name "user_123" + uid = int(room.split("_", 1)[1]) + filtered = self._queue_status_fn(user_id=uid) if self._queue_status_fn else None + if filtered is not None: + self.socketio.emit("status_update", filtered, to=room) + except Exception: + logger.exception("Failed to send status update for room %s", room) + + def broadcast_download_progress( + self, book_id: str, progress: float, status: str, user_id: int | None = None + ) -> None: """Broadcast download progress update for a specific book.""" if not self.is_enabled(): return try: - data = { - 'book_id': book_id, - 'progress': progress, - 'status': status - } + data = {"book_id": book_id, "progress": progress, "status": status} # Admins always see all progress - self.socketio.emit('download_progress', data, to="admins") + self.socketio.emit("download_progress", data, to="admins") # If task belongs to a specific user, send to their room too if user_id is not None: room = f"user_{user_id}" with self._rooms_lock: if room in self._user_rooms: - self.socketio.emit('download_progress', data, to=room) - logger.debug(f"Broadcasted progress for book {book_id}: {progress}%") - except Exception as e: - logger.error(f"Error broadcasting download progress: {e}") - - def broadcast_notification(self, message: str, notification_type: str = 'info'): - """Broadcast a notification message to all clients.""" - if not self.is_enabled(): - return - - try: - data = { - 'message': message, - 'type': notification_type - } - # When calling socketio.emit() outside event handlers, it broadcasts by default - self.socketio.emit('notification', data) - logger.debug(f"Broadcasted notification: {message}") - except Exception as e: - logger.error(f"Error broadcasting notification: {e}") + self.socketio.emit("download_progress", data, to=room) + logger.debug("Broadcasted progress for book %s: %s%%", book_id, progress) + except Exception: + logger.exception("Error broadcasting download progress") def broadcast_search_status( self, @@ -228,23 +179,23 @@ class WebSocketManager: provider: str, book_id: str, message: str, - phase: str = 'searching' - ): + phase: str = "searching", + ) -> None: """Broadcast search status update for a release source search.""" if not self.is_enabled(): return try: data = { - 'source': source, - 'provider': provider, - 'book_id': book_id, - 'message': message, - 'phase': phase, + "source": source, + "provider": provider, + "book_id": book_id, + "message": message, + "phase": phase, } - self.socketio.emit('search_status', data) - except Exception as e: - logger.error(f"Error broadcasting search status: {e}") + self.socketio.emit("search_status", data) + except Exception: + logger.exception("Error broadcasting search status") # Global WebSocket manager instance diff --git a/shelfmark/bypass/external_bypasser.py b/shelfmark/bypass/external_bypasser.py index 1d987818..6b9c66a7 100644 --- a/shelfmark/bypass/external_bypasser.py +++ b/shelfmark/bypass/external_bypasser.py @@ -2,8 +2,7 @@ import random import time -from threading import Event -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING import requests @@ -14,6 +13,8 @@ from shelfmark.core.utils import normalize_http_url from shelfmark.download.network import get_ssl_verify if TYPE_CHECKING: + from threading import Event + from shelfmark.download import network logger = setup_logger(__name__) @@ -29,7 +30,7 @@ BACKOFF_BASE = 1.0 BACKOFF_CAP = 10.0 -def _fetch_via_bypasser(target_url: str) -> Optional[str]: +def _fetch_via_bypasser(target_url: str) -> str | None: """Make a single request to the external bypasser service. Returns HTML or None.""" raw_bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191") bypasser_path = config.get("EXT_BYPASSER_PATH", "/v1") @@ -37,7 +38,9 @@ def _fetch_via_bypasser(target_url: str) -> Optional[str]: bypasser_url = normalize_http_url(raw_bypasser_url) if not bypasser_url or not bypasser_path: - logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.") + logger.error( + "External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH." + ) return None read_timeout = min((bypasser_timeout / 1000) + READ_TIMEOUT_BUFFER, MAX_READ_TIMEOUT) @@ -46,48 +49,63 @@ def _fetch_via_bypasser(target_url: str) -> Optional[str]: response = requests.post( f"{bypasser_url}{bypasser_path}", headers={"Content-Type": "application/json"}, - json={"cmd": "request.get", "url": target_url, "maxTimeout": bypasser_timeout}, + json={ + "cmd": "request.get", + "url": target_url, + "maxTimeout": bypasser_timeout, + }, timeout=(CONNECT_TIMEOUT, read_timeout), verify=get_ssl_verify(bypasser_url), ) response.raise_for_status() result = response.json() - status = result.get('status', 'unknown') - message = result.get('message', '') - logger.debug(f"External bypasser response for '{target_url}': {status} - {message}") + status = result.get("status", "unknown") + message = result.get("message", "") + logger.debug("External bypasser response for '%s': %s - %s", target_url, status, message) - if status != 'ok': - logger.warning(f"External bypasser failed for '{target_url}': {status} - {message}") + if status != "ok": + logger.warning( + "External bypasser failed for '%s': %s - %s", + target_url, + status, + message, + ) return None - solution = result.get('solution') - html = solution.get('response', '') if solution else '' + solution = result.get("solution") + html = solution.get("response", "") if solution else "" if not html: - logger.warning(f"External bypasser returned empty response for '{target_url}'") + logger.warning("External bypasser returned empty response for '%s'", target_url) return None - return html - except requests.exceptions.Timeout: - logger.warning(f"External bypasser timed out for '{target_url}' (connect: {CONNECT_TIMEOUT}s, read: {read_timeout:.0f}s)") + logger.warning( + "External bypasser timed out for '%s' (connect: %ss, read: %.0fs)", + target_url, + CONNECT_TIMEOUT, + read_timeout, + ) except requests.exceptions.RequestException as e: - logger.warning(f"External bypasser request failed for '{target_url}': {e}") + logger.warning("External bypasser request failed for '%s': %s", target_url, e) except (KeyError, TypeError, ValueError) as e: - logger.warning(f"External bypasser returned malformed response for '{target_url}': {e}") + logger.warning("External bypasser returned malformed response for '%s': %s", target_url, e) + else: + return html return None -def _check_cancelled(cancel_flag: Optional[Event], context: str) -> None: +def _check_cancelled(cancel_flag: Event | None, context: str) -> None: """Check if operation was cancelled and raise exception if so.""" if cancel_flag and cancel_flag.is_set(): - logger.info(f"External bypasser cancelled {context}") - raise BypassCancelledException("Bypass cancelled") + logger.info("External bypasser cancelled %s", context) + msg = "Bypass cancelled" + raise BypassCancelledException(msg) -def _sleep_with_cancellation(seconds: float, cancel_flag: Optional[Event]) -> None: +def _sleep_with_cancellation(seconds: float, cancel_flag: Event | None) -> None: """Sleep for the specified duration, checking for cancellation each second.""" for _ in range(int(seconds)): _check_cancelled(cancel_flag, "during backoff") @@ -99,9 +117,9 @@ def _sleep_with_cancellation(seconds: float, cancel_flag: Optional[Event]) -> No def get_bypassed_page( url: str, - selector: Optional["network.AAMirrorSelector"] = None, - cancel_flag: Optional[Event] = None -) -> Optional[str]: + selector: network.AAMirrorSelector | None = None, + cancel_flag: Event | None = None, +) -> str | None: """Fetch HTML via external bypasser with retries and mirror rotation.""" from shelfmark.download import network as network_module @@ -119,12 +137,17 @@ def get_bypassed_page( break delay = min(BACKOFF_CAP, BACKOFF_BASE * (2 ** (attempt - 1))) + random.random() - logger.info(f"External bypasser attempt {attempt}/{MAX_RETRY} failed, retrying in {delay:.1f}s") + logger.info( + "External bypasser attempt %s/%s failed, retrying in %.1fs", + attempt, + MAX_RETRY, + delay, + ) _sleep_with_cancellation(delay, cancel_flag) new_base, action = sel.next_mirror_or_rotate_dns() if action in ("mirror", "dns") and new_base: - logger.info(f"Rotated {action} for retry") + logger.info("Rotated %s for retry", action) return None diff --git a/shelfmark/bypass/fingerprint.py b/shelfmark/bypass/fingerprint.py index 371208f1..c1c64562 100644 --- a/shelfmark/bypass/fingerprint.py +++ b/shelfmark/bypass/fingerprint.py @@ -1,34 +1,37 @@ """Browser fingerprint profile management for bypass stealth.""" import random -from typing import Optional from shelfmark.core.logger import setup_logger logger = setup_logger(__name__) COMMON_RESOLUTIONS = [ - (1920, 1080, 0.35), - (1366, 768, 0.18), - (1536, 864, 0.10), - (1440, 900, 0.08), - (1280, 720, 0.07), - (1600, 900, 0.06), - (1280, 800, 0.05), - (2560, 1440, 0.04), - (1680, 1050, 0.04), - (1920, 1200, 0.03), + (1920, 1080, 0.35), + (1366, 768, 0.18), + (1536, 864, 0.10), + (1440, 900, 0.08), + (1280, 720, 0.07), + (1600, 900, 0.06), + (1280, 800, 0.05), + (2560, 1440, 0.04), + (1680, 1050, 0.04), + (1920, 1200, 0.03), ] # Current screen size (module-level singleton) -_current_screen_size: Optional[tuple[int, int]] = None +_current_screen_size: tuple[int, int] | None = None def get_screen_size() -> tuple[int, int]: global _current_screen_size if _current_screen_size is None: _current_screen_size = _generate_screen_size() - logger.debug(f"Generated initial screen size: {_current_screen_size[0]}x{_current_screen_size[1]}") + logger.debug( + "Generated initial screen size: %sx%s", + _current_screen_size[0], + _current_screen_size[1], + ) return _current_screen_size @@ -39,9 +42,15 @@ def rotate_screen_size() -> tuple[int, int]: width, height = _current_screen_size if old_size: - logger.info(f"Rotated screen size: {old_size[0]}x{old_size[1]} -> {width}x{height}") + logger.info( + "Rotated screen size: %sx%s -> %sx%s", + old_size[0], + old_size[1], + width, + height, + ) else: - logger.info(f"Generated screen size: {width}x{height}") + logger.info("Generated screen size: %sx%s", width, height) return _current_screen_size diff --git a/shelfmark/bypass/internal_bypasser.py b/shelfmark/bypass/internal_bypasser.py index 3c785af0..2de89114 100644 --- a/shelfmark/bypass/internal_bypasser.py +++ b/shelfmark/bypass/internal_bypasser.py @@ -8,9 +8,11 @@ import subprocess import threading import time import traceback +from contextlib import suppress from datetime import datetime +from pathlib import Path from threading import Event -from typing import Any, Optional +from typing import Any from urllib.parse import urlparse import requests @@ -28,8 +30,8 @@ from shelfmark.download.network import get_proxies, get_ssl_verify logger = setup_logger(__name__) -SELENIUMBASE_RUNTIME_ROOT = "/tmp/shelfmark/seleniumbase" -SELENIUMBASE_DOWNLOADS_DIR = os.path.join(SELENIUMBASE_RUNTIME_ROOT, "downloaded_files") +SELENIUMBASE_RUNTIME_ROOT = Path("/tmp/shelfmark/seleniumbase") +SELENIUMBASE_DOWNLOADS_DIR = SELENIUMBASE_RUNTIME_ROOT / "downloaded_files" # Challenge detection indicators CLOUDFLARE_INDICATORS = [ @@ -54,13 +56,14 @@ DISPLAY = { LOCKED = threading.Lock() -def _describe_runtime_path(path: str) -> str: +def _describe_runtime_path(path: str | Path) -> str: """Return compact ownership/mode info for a runtime path.""" try: + path = Path(path) link_target = "" - if os.path.islink(path): - link_target = f" -> {os.readlink(path)}" - st = os.stat(path) + if path.is_symlink(): + link_target = f" -> {path.readlink()}" + st = path.stat() mode = stat.S_IMODE(st.st_mode) return f"{path}{link_target} exists uid={st.st_uid} gid={st.st_gid} mode={oct(mode)}" except FileNotFoundError: @@ -71,8 +74,8 @@ def _describe_runtime_path(path: str) -> str: class _CdpWorker: def __init__(self) -> None: - self._thread: Optional[threading.Thread] = None - self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None self._ready = threading.Event() self._lock = threading.Lock() @@ -107,7 +110,7 @@ class _CdpWorker: if not self._ready.wait(timeout=10): raise RuntimeError("CDP worker loop failed to start") - def run(self, coro: Any, timeout: Optional[float] = None) -> Any: + def run(self, coro: Any, timeout: float | None = None) -> Any: self.start() if not self._loop or self._loop.is_closed(): raise RuntimeError("CDP worker loop not available") @@ -126,24 +129,34 @@ _cf_cookies_lock = threading.Lock() _cf_user_agents: dict[str, str] = {} # Protection cookie names we care about (Cloudflare and DDoS-Guard) -CF_COOKIE_NAMES = {'cf_clearance', '__cf_bm', 'cf_chl_2', 'cf_chl_prog'} -DDG_COOKIE_NAMES = {'__ddg1_', '__ddg2_', '__ddg5_', '__ddg8_', '__ddg9_', '__ddg10_', '__ddgid_', '__ddgmark_', 'ddg_last_challenge'} +CF_COOKIE_NAMES = {"cf_clearance", "__cf_bm", "cf_chl_2", "cf_chl_prog"} +DDG_COOKIE_NAMES = { + "__ddg1_", + "__ddg2_", + "__ddg5_", + "__ddg8_", + "__ddg9_", + "__ddg10_", + "__ddgid_", + "__ddgmark_", + "ddg_last_challenge", +} # Domains requiring full session cookies (not just protection cookies) -FULL_COOKIE_DOMAINS = {'z-lib.fm', 'z-lib.gs', 'z-lib.id', 'z-library.sk', 'zlibrary-global.se'} +FULL_COOKIE_DOMAINS = {"z-lib.fm", "z-lib.gs", "z-lib.id", "z-library.sk", "zlibrary-global.se"} def _get_base_domain(domain: str) -> str: """Extract base domain from hostname (e.g., 'www.example.com' -> 'example.com').""" - return '.'.join(domain.split('.')[-2:]) if '.' in domain else domain + return ".".join(domain.split(".")[-2:]) if "." in domain else domain -def _should_extract_cookie(name: str, extract_all: bool) -> bool: +def _should_extract_cookie(name: str, *, extract_all: bool) -> bool: """Determine if a cookie should be extracted based on its name.""" if extract_all: return True - is_cf = name in CF_COOKIE_NAMES or name.startswith('cf_') - is_ddg = name in DDG_COOKIE_NAMES or name.startswith('__ddg') + is_cf = name in CF_COOKIE_NAMES or name.startswith("cf_") + is_ddg = name in DDG_COOKIE_NAMES or name.startswith("__ddg") return is_cf or is_ddg @@ -151,7 +164,7 @@ def _store_extracted_cookies( *, url: str, cookies: list[Any], - user_agent: Optional[str] = None, + user_agent: str | None = None, ) -> None: """Store filtered bypass cookies (and optional UA) for a URL domain.""" parsed = urlparse(url) @@ -165,7 +178,7 @@ def _store_extracted_cookies( cookies_found: dict[str, dict[str, Any]] = {} for cookie in cookies: name = getattr(cookie, "name", "") or "" - if not _should_extract_cookie(name, extract_all): + if not _should_extract_cookie(name, extract_all=extract_all): continue expires = getattr(cookie, "expires", None) if expires is not None and expires <= 0: @@ -186,21 +199,21 @@ def _store_extracted_cookies( _cf_cookies[base_domain] = cookies_found if user_agent: _cf_user_agents[base_domain] = user_agent - logger.debug(f"Stored UA for {base_domain}: {str(user_agent)[:60]}...") + logger.debug("Stored UA for %s: %s...", base_domain, str(user_agent)[:60]) else: - logger.debug(f"No UA captured for {base_domain}") + logger.debug("No UA captured for %s", base_domain) cookie_type = "all" if extract_all else "protection" - logger.debug(f"Extracted {len(cookies_found)} {cookie_type} cookies for {base_domain}") + logger.debug("Extracted %s %s cookies for %s", len(cookies_found), cookie_type, base_domain) -async def _extract_cookies_from_cdp(driver, page, url: str) -> None: +async def _extract_cookies_from_cdp(driver: Any, page: Any, url: str) -> None: """Extract cookies from a CDP browser after successful bypass.""" try: try: all_cookies = await driver.cookies.get_all(requests_cookie_format=True) except Exception as e: - logger.debug(f"Failed to get cookies via CDP: {e}") + logger.debug("Failed to get cookies via CDP: %s", e) return try: @@ -211,7 +224,8 @@ async def _extract_cookies_from_cdp(driver, page, url: str) -> None: _store_extracted_cookies(url=url, cookies=all_cookies, user_agent=user_agent) except Exception as e: - logger.debug(f"Failed to extract cookies: {e}") + logger.debug("Failed to extract cookies: %s", e) + def get_cf_cookies_for_domain(domain: str) -> dict[str, str]: """Get stored cookies for a domain. Returns empty dict if none available.""" @@ -225,17 +239,17 @@ def get_cf_cookies_for_domain(domain: str) -> dict[str, str]: if not cookies: return {} - cf_clearance = cookies.get('cf_clearance', {}) + cf_clearance = cookies.get("cf_clearance", {}) if cf_clearance: - expiry = cf_clearance.get('expiry') + expiry = cf_clearance.get("expiry") if expiry is None: - expiry = cf_clearance.get('expires') + expiry = cf_clearance.get("expires") if expiry and expiry > 0 and time.time() > expiry: - logger.debug(f"CF cookies expired for {base_domain}") + logger.debug("CF cookies expired for %s", base_domain) _cf_cookies.pop(base_domain, None) return {} - return {name: c['value'] for name, c in cookies.items()} + return {name: c["value"] for name, c in cookies.items()} def has_valid_cf_cookies(domain: str) -> bool: @@ -243,7 +257,7 @@ def has_valid_cf_cookies(domain: str) -> bool: return bool(get_cf_cookies_for_domain(domain)) -def get_cf_user_agent_for_domain(domain: str) -> Optional[str]: +def get_cf_user_agent_for_domain(domain: str) -> str | None: """Get the User-Agent that was used during bypass for a domain.""" if not domain: return None @@ -251,7 +265,7 @@ def get_cf_user_agent_for_domain(domain: str) -> Optional[str]: return _cf_user_agents.get(_get_base_domain(domain)) -def clear_cf_cookies(domain: str = None) -> None: +def clear_cf_cookies(domain: str | None = None) -> None: """Clear stored Cloudflare cookies and User-Agent. If domain is None, clear all.""" with _cf_cookies_lock: if domain: @@ -279,43 +293,39 @@ def _cleanup_orphan_processes() -> int: for proc_name in processes_to_kill: try: result = subprocess.run( - ["pgrep", "-f", proc_name], - capture_output=True, - text=True, - timeout=5 + ["pgrep", "-f", proc_name], capture_output=True, text=True, timeout=5 ) if result.returncode != 0 or not result.stdout.strip(): continue - pids = result.stdout.strip().split('\n') + pids = result.stdout.strip().split("\n") count = len(pids) - logger.info(f"Found {count} orphan {proc_name} process(es), killing...") + logger.info("Found %s orphan %s process(es), killing...", count, proc_name) kill_result = subprocess.run( - ["pkill", "-9", "-f", proc_name], - capture_output=True, - timeout=5 + ["pkill", "-9", "-f", proc_name], capture_output=True, timeout=5 ) if kill_result.returncode == 0: total_killed += count else: - logger.warning(f"pkill for {proc_name} returned {kill_result.returncode}") + logger.warning("pkill for %s returned %s", proc_name, kill_result.returncode) except subprocess.TimeoutExpired: - logger.warning(f"Timeout while checking for {proc_name} processes") + logger.warning("Timeout while checking for %s processes", proc_name) except Exception as e: - logger.debug(f"Error checking for {proc_name} processes: {e}") + logger.debug("Error checking for %s processes: %s", proc_name, e) if total_killed > 0: time.sleep(1) - logger.info(f"Cleaned up {total_killed} orphan process(es)") + logger.info("Cleaned up %s orphan process(es)", total_killed) logger.log_resource_usage() else: logger.debug("No orphan processes found") return total_killed -async def _get_page_info(page) -> tuple[str, str, str]: + +async def _get_page_info(page: Any) -> tuple[str, str, str]: """Extract page title, body text, and current URL safely.""" try: title = (await page.get_title() or "").lower() @@ -333,30 +343,35 @@ async def _get_page_info(page) -> tuple[str, str, str]: return title, body, current_url -def _check_indicators(title: str, body: str, indicators: list[str]) -> Optional[str]: +def _check_indicators(title: str, body: str, indicators: list[str]) -> str | None: """Check if any indicator is present in title or body. Returns the found indicator or None.""" for indicator in indicators: if indicator in title or indicator in body: return indicator return None + def _has_cloudflare_patterns(body: str, url: str) -> bool: """Check for Cloudflare-specific patterns in body or URL.""" return "cf-" in body or "cloudflare" in url.lower() or "/cdn-cgi/" in url -async def _detect_challenge_type(page) -> str: + +async def _detect_challenge_type(page: Any) -> str: """Detect challenge type: 'cloudflare', 'ddos_guard', or 'none'.""" try: title, body, current_url = await _get_page_info(page) - + except Exception as e: + logger.warning("Error detecting challenge type: %s", e) + return "none" + else: # DDOS-Guard indicators if found := _check_indicators(title, body, DDOS_GUARD_INDICATORS): - logger.debug(f"DDOS-Guard indicator found: '{found}'") + logger.debug("DDOS-Guard indicator found: '%s'", found) return "ddos_guard" # Cloudflare indicators if found := _check_indicators(title, body, CLOUDFLARE_INDICATORS): - logger.debug(f"Cloudflare indicator found: '{found}'") + logger.debug("Cloudflare indicator found: '%s'", found) return "cloudflare" # Check URL patterns @@ -364,24 +379,28 @@ async def _detect_challenge_type(page) -> str: return "cloudflare" return "none" - except Exception as e: - logger.warning(f"Error detecting challenge type: {e}") - return "none" -async def _is_bypassed(page, escape_emojis: bool = True) -> bool: + +async def _is_bypassed(page: Any, *, escape_emojis: bool = True) -> bool: """Check if the protection has been bypassed.""" try: title, body, current_url = await _get_page_info(page) + except Exception as e: + logger.warning("Error checking bypass status: %s", e) + return False + + else: body_len = len(body.strip()) # Long page content = probably bypassed if body_len > 100000: - logger.debug(f"Page content too long, probably bypassed (len: {body_len})") + logger.debug("Page content too long, probably bypassed (len: %s)", body_len) return True # Multiple emojis = probably real content if escape_emojis: import emoji + if len(emoji.emoji_list(body)) >= 3: logger.debug("Detected emojis in page, probably bypassed") return True @@ -400,14 +419,11 @@ async def _is_bypassed(page, escape_emojis: bool = True) -> bool: logger.debug("Page content too short, might still be loading") return False - logger.debug(f"Bypass check passed - Title: '{title[:100]}', Body length: {body_len}") + logger.debug("Bypass check passed - Title: '%s', Body length: %s", title[:100], body_len) return True - except Exception as e: - logger.warning(f"Error checking bypass status: {e}") - return False -async def _bypass_method_humanlike(page) -> bool: +async def _bypass_method_humanlike(page: Any) -> bool: """Human-like behavior with scroll, wait, and reload.""" try: logger.debug("Attempting bypass: human-like interaction") @@ -421,7 +437,7 @@ async def _bypass_method_humanlike(page) -> bool: await page.wait() await asyncio.sleep(random.uniform(2, 3)) except Exception as e: - logger.debug(f"Scroll behavior failed: {e}") + logger.debug("Scroll behavior failed: %s", e) if await _is_bypassed(page): return True @@ -437,15 +453,15 @@ async def _bypass_method_humanlike(page) -> bool: await page.solve_captcha() await asyncio.sleep(random.uniform(3, 5)) except Exception as e: - logger.debug(f"Final captcha click failed: {e}") + logger.debug("Final captcha click failed: %s", e) return await _is_bypassed(page) except Exception as e: - logger.debug(f"Human-like method failed: {e}") + logger.debug("Human-like method failed: %s", e) return False -async def _bypass_method_cdp_solve(page) -> bool: +async def _bypass_method_cdp_solve(page: Any) -> bool: """CDP Mode with solve_captcha() - auto-detects challenge type.""" try: logger.debug("Attempting bypass: CDP solve_captcha") @@ -453,21 +469,21 @@ async def _bypass_method_cdp_solve(page) -> bool: await asyncio.sleep(random.uniform(3, 5)) return await _is_bypassed(page) except Exception as e: - logger.debug(f"CDP solve_captcha failed: {e}") + logger.debug("CDP solve_captcha failed: %s", e) return False CDP_CLICK_SELECTORS = [ - "#turnstile-widget div", # Cloudflare Turnstile - "#cf-turnstile div", # Alternative CF Turnstile + "#turnstile-widget div", # Cloudflare Turnstile + "#cf-turnstile div", # Alternative CF Turnstile "iframe[src*='challenges']", # CF challenge iframe - "input[type='checkbox']", # Generic checkbox (DDOS-Guard) - "[class*='checkbox']", # Class-based checkbox - "#challenge-running", # CF challenge indicator + "input[type='checkbox']", # Generic checkbox (DDOS-Guard) + "[class*='checkbox']", # Class-based checkbox + "#challenge-running", # CF challenge indicator ] -async def _bypass_method_cdp_click(page) -> bool: +async def _bypass_method_cdp_click(page: Any) -> bool: """CDP Mode with native clicking - no PyAutoGUI dependency.""" try: logger.debug("Attempting bypass: CDP native click") @@ -477,31 +493,31 @@ async def _bypass_method_cdp_click(page) -> bool: if not await page.is_element_visible(selector): continue - logger.debug(f"CDP clicking: {selector}") + logger.debug("CDP clicking: %s", selector) await page.click(selector) await asyncio.sleep(random.uniform(2, 4)) if await _is_bypassed(page): return True except Exception as e: - logger.debug(f"CDP click on '{selector}' failed: {e}") + logger.debug("CDP click on '%s' failed: %s", selector, e) return await _is_bypassed(page) except Exception as e: - logger.debug(f"CDP Mode click failed: {e}") + logger.debug("CDP Mode click failed: %s", e) return False CDP_GUI_CLICK_SELECTORS = [ - "#turnstile-widget div", # Cloudflare Turnstile - "#cf-turnstile div", # Alternative CF Turnstile - "#challenge-stage div", # CF challenge stage - "input[type='checkbox']", # Generic checkbox - "[class*='cb-i']", # DDOS-Guard checkbox + "#turnstile-widget div", # Cloudflare Turnstile + "#cf-turnstile div", # Alternative CF Turnstile + "#challenge-stage div", # CF challenge stage + "input[type='checkbox']", # Generic checkbox + "[class*='cb-i']", # DDOS-Guard checkbox ] -async def _bypass_method_cdp_gui_click(page) -> bool: +async def _bypass_method_cdp_gui_click(page: Any) -> bool: """CDP Mode with gui_click-style behavior.""" try: logger.debug("Attempting bypass: CDP gui_click (mouse-based)") @@ -514,25 +530,25 @@ async def _bypass_method_cdp_gui_click(page) -> bool: if await _is_bypassed(page): return True except Exception as e: - logger.debug(f"solve_captcha() failed: {e}") + logger.debug("solve_captcha() failed: %s", e) for selector in CDP_GUI_CLICK_SELECTORS: try: if not await page.is_element_visible(selector): continue - logger.debug(f"CDP click_with_offset: {selector}") + logger.debug("CDP click_with_offset: %s", selector) await page.click_with_offset(selector, 0, 0, center=True) await asyncio.sleep(random.uniform(3, 5)) if await _is_bypassed(page): return True except Exception as e: - logger.debug(f"CDP gui_click on '{selector}' failed: {e}") + logger.debug("CDP gui_click on '%s' failed: %s", selector, e) return await _is_bypassed(page) except Exception as e: - logger.debug(f"CDP Mode gui_click failed: {e}") + logger.debug("CDP Mode gui_click failed: %s", e) return False @@ -546,14 +562,16 @@ BYPASS_METHODS = [ MAX_CONSECUTIVE_SAME_CHALLENGE = 3 -def _check_cancellation(cancel_flag: Optional[Event], message: str) -> None: +def _check_cancellation(cancel_flag: Event | None, message: str) -> None: """Check if cancellation was requested and raise if so.""" if cancel_flag and cancel_flag.is_set(): logger.info(message) raise BypassCancelledException("Bypass cancelled") -async def _bypass(page, max_retries: Optional[int] = None, cancel_flag: Optional[Event] = None) -> bool: +async def _bypass( + page: Any, max_retries: int | None = None, cancel_flag: Event | None = None +) -> bool: """Attempt to bypass Cloudflare/DDOS-Guard protection using multiple methods.""" max_retries = max_retries if max_retries is not None else app_config.MAX_RETRY @@ -571,7 +589,7 @@ async def _bypass(page, max_retries: Optional[int] = None, cancel_flag: Optional return True challenge_type = await _detect_challenge_type(page) - logger.debug(f"Challenge detected: {challenge_type}") + logger.debug("Challenge detected: %s", challenge_type) # No challenge detected but page doesn't look bypassed - wait and retry if challenge_type == "none": @@ -587,14 +605,16 @@ async def _bypass(page, max_retries: Optional[int] = None, cancel_flag: Optional logger.info("Bypass successful after refresh") return True except Exception as e: - logger.debug(f"Refresh during no-challenge wait failed: {e}") + logger.debug("Refresh during no-challenge wait failed: %s", e) continue if challenge_type == last_challenge_type: consecutive_same_challenge += 1 if consecutive_same_challenge >= min_same_challenge_before_abort: logger.warning( - f"Same challenge ({challenge_type}) detected {consecutive_same_challenge} times - aborting" + "Same challenge (%s) detected %s times - aborting", + challenge_type, + consecutive_same_challenge, ) return False else: @@ -602,11 +622,11 @@ async def _bypass(page, max_retries: Optional[int] = None, cancel_flag: Optional last_challenge_type = challenge_type method = BYPASS_METHODS[try_count % len(BYPASS_METHODS)] - logger.info(f"Bypass attempt {try_count + 1}/{max_retries} using {method.__name__}") + logger.info("Bypass attempt %s/%s using %s", try_count + 1, max_retries, method.__name__) if try_count > 0: wait_time = min(random.uniform(2, 4) * try_count, 12) - logger.info(f"Waiting {wait_time:.1f}s before trying...") + logger.info("Waiting %0.1fs before trying...", wait_time) for _ in range(int(wait_time)): _check_cancellation(cancel_flag, "Bypass cancelled during wait") await asyncio.sleep(1) @@ -614,18 +634,19 @@ async def _bypass(page, max_retries: Optional[int] = None, cancel_flag: Optional try: if await method(page): - logger.info(f"Bypass successful using {method.__name__}") + logger.info("Bypass successful using %s", method.__name__) return True except BypassCancelledException: raise except Exception as e: - logger.warning(f"Exception in {method.__name__}: {e}") + logger.warning("Exception in %s: %s", method.__name__, e) - logger.info(f"Bypass method {method.__name__} failed.") + logger.info("Bypass method %s failed.", method.__name__) logger.warning("Exceeded maximum retries. Bypass failed.") return False + def _get_browser_args() -> list[str]: """Build extra Chrome arguments, pre-resolving hostnames via patched DNS. @@ -645,16 +666,14 @@ def _get_browser_args() -> list[str]: ] if app_config.get("DEBUG", False): - arguments.extend([ - "--enable-logging", - "--v=1", - "--log-file=" + str(LOG_DIR / "chrome_browser.log") - ]) + arguments.extend( + ["--enable-logging", "--v=1", "--log-file=" + str(LOG_DIR / "chrome_browser.log")] + ) host_rules = _build_host_resolver_rules() if host_rules: - arguments.append(f'--host-resolver-rules={", ".join(host_rules)}') - logger.debug(f"Chrome: Using host resolver rules for {len(host_rules)} hosts") + arguments.append(f"--host-resolver-rules={', '.join(host_rules)}") + logger.debug("Chrome: Using host resolver rules for %s hosts", len(host_rules)) else: logger.warning("Chrome: No hosts could be pre-resolved") @@ -676,40 +695,39 @@ def _build_host_resolver_rules() -> list[str]: if results: ip = results[0][4][0] host_rules.append(f"MAP {hostname} {ip}") - logger.debug(f"Chrome: Pre-resolved {hostname} -> {ip}") + logger.debug("Chrome: Pre-resolved %s -> %s", hostname, ip) else: - logger.warning(f"Chrome: No addresses returned for {hostname}") + logger.warning("Chrome: No addresses returned for %s", hostname) except socket.gaierror as e: - logger.warning(f"Chrome: Could not pre-resolve {hostname}: {e}") + logger.warning("Chrome: Could not pre-resolve %s: %s", hostname, e) except Exception as e: logger.error_trace(f"Error pre-resolving hostnames for Chrome: {e}") return host_rules + DRIVER_RESET_ERRORS = {"ProtocolException", "RuntimeError", "TimeoutError"} -async def _get(url: str, driver, cancel_flag: Optional[Event] = None) -> str: +async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str: """Fetch URL with Cloudflare bypass using a CDP browser.""" _check_cancellation(cancel_flag, "Bypass cancelled before starting") - logger.debug(f"CDP_GET: {url}") + logger.debug("CDP_GET: %s", url) logger.debug("Opening URL with SeleniumBase CDP...") page = await driver.get(url) - try: + with suppress(Exception): await page.wait() - except Exception: - pass _check_cancellation(cancel_flag, "Bypass cancelled after page load") try: current_url = await page.get_current_url() title = await page.get_title() - logger.debug(f"Page loaded - URL: {current_url}, Title: {title}") + logger.debug("Page loaded - URL: %s, Title: %s", current_url, title) except Exception as e: - logger.debug(f"Could not get page info: {e}") + logger.debug("Could not get page info: %s", e) logger.debug("Starting bypass process...") if await _bypass(page, cancel_flag=cancel_flag): @@ -727,7 +745,7 @@ async def _get(url: str, driver, cancel_flag: Optional[Event] = None) -> str: return "" -def get(url: str, retry: Optional[int] = None, cancel_flag: Optional[Event] = None) -> str: +def get(url: str, retry: int | None = None, cancel_flag: Event | None = None) -> str: """Fetch a URL with protection bypass. Creates fresh Chrome instance for each bypass.""" retry = retry if retry is not None else app_config.MAX_RETRY @@ -753,8 +771,10 @@ def get(url: str, retry: Optional[int] = None, cancel_flag: Optional[Event] = No raise except Exception as e: error_details = f"{type(e).__name__}: {e}" - logger.warning(f"Bypass failed (attempt {attempt + 1}/{retry}): {error_details}") - logger.debug(f"Stack trace: {traceback.format_exc()}") + logger.warning( + "Bypass failed (attempt %s/%s): %s", attempt + 1, retry, error_details + ) + logger.debug("Stack trace: %s", traceback.format_exc()) # On CDP errors, quit and create a fresh browser if type(e).__name__ in DRIVER_RESET_ERRORS: @@ -762,7 +782,7 @@ def get(url: str, retry: Optional[int] = None, cancel_flag: Optional[Event] = No await _close_cdp_driver(driver) driver = await _create_cdp_browser(url) - logger.error(f"Bypass failed after {retry} attempts") + logger.error("Bypass failed after %s attempts", retry) return "" finally: if driver: @@ -770,7 +790,8 @@ def get(url: str, retry: Optional[int] = None, cancel_flag: Optional[Event] = No return _CDP_WORKER.run(_run_bypass()) -def _get_proxy_string(url: str) -> Optional[str]: + +def _get_proxy_string(url: str) -> str | None: """Return a single proxy string for CDP, honoring NO_PROXY.""" proxies = get_proxies(url) if not proxies: @@ -787,8 +808,8 @@ async def _create_cdp_browser(url: str) -> Any: display_height = screen_height + 150 proxy = _get_proxy_string(url) - logger.debug(f"Creating Pure CDP browser with args: {browser_args}") - logger.debug(f"Browser screen size: {screen_width}x{screen_height}") + logger.debug("Creating Pure CDP browser with args: %s", browser_args) + logger.debug("Browser screen size: %sx%s", screen_width, screen_height) try: driver = await cdp_driver.start_async( @@ -804,21 +825,21 @@ async def _create_cdp_browser(url: str) -> Any: browser_args=browser_args, ) except Exception as e: - logger.warning(f"Pure CDP browser startup failed: {type(e).__name__}: {e}") + logger.warning("Pure CDP browser startup failed: %s: %s", type(e).__name__, e) logger.warning( - "SeleniumBase runtime paths: " - f"cwd={os.getcwd()}; " - f"{_describe_runtime_path(SELENIUMBASE_DOWNLOADS_DIR)}; " - f"{_describe_runtime_path('/app/downloaded_files')}; " - f"{_describe_runtime_path('downloaded_files')}; " - f"{_describe_runtime_path('/tmp')}" + "SeleniumBase runtime paths: cwd=%s; %s; %s; %s; %s", + Path.cwd(), + _describe_runtime_path(SELENIUMBASE_DOWNLOADS_DIR), + _describe_runtime_path("/app/downloaded_files"), + _describe_runtime_path("downloaded_files"), + _describe_runtime_path("/tmp"), ) raise try: await driver.page.set_window_rect(0, 0, screen_width, screen_height) except Exception as e: - logger.debug(f"Failed to set window size: {e}") + logger.debug("Failed to set window size: %s", e) # Start FFmpeg recording if debug mode (record each bypass session) if app_config.get("DEBUG", False) and not DISPLAY.get("ffmpeg"): @@ -830,7 +851,7 @@ async def _create_cdp_browser(url: str) -> Any: return driver -async def _close_cdp_driver(driver) -> None: +async def _close_cdp_driver(driver: Any) -> None: """Close CDP connections and stop the browser.""" if not driver: return @@ -846,18 +867,15 @@ async def _close_cdp_driver(driver) -> None: if hasattr(driver, "targets") and driver.targets: connections.extend(driver.targets) for conn in connections: - try: - await conn.aclose() - except Exception as e: - logger.debug(f"Failed to close websocket connection: {e}") + await _close_websocket_connection(conn) except Exception as e: - logger.debug(f"Error during connection cleanup: {e}") + logger.debug("Error during connection cleanup: %s", e) try: driver.stop() logger.debug("Stopped CDP browser") except Exception as e: - logger.debug(f"CDP stop: {e}") + logger.debug("CDP stop: %s", e) if env.DOCKERMODE: await asyncio.sleep(0.3) @@ -879,15 +897,23 @@ async def _close_cdp_driver(driver) -> None: await asyncio.sleep(0.1) if _pid_alive(pid): os.kill(pid, signal.SIGKILL) - logger.debug(f"Killed Chrome pid {pid}") + logger.debug("Killed Chrome pid %s", pid) except Exception as e: - logger.debug(f"Failed to kill Chrome pid {pid}: {e}") + logger.debug("Failed to kill Chrome pid %s: %s", pid, e) except Exception as e: - logger.debug(f"Process cleanup failed: {e}") + logger.debug("Process cleanup failed: %s", e) logger.log_resource_usage() +async def _close_websocket_connection(conn: Any) -> None: + """Close one websocket-like connection, ignoring best-effort failures.""" + try: + await conn.aclose() + except Exception as e: + logger.debug("Failed to close websocket connection: %s", e) + + def _start_ffmpeg_recording(display: str) -> None: """Start FFmpeg screen recording for debug mode.""" global DISPLAY @@ -900,16 +926,37 @@ def _start_ffmpeg_recording(display: str) -> None: display_height = screen_height + 150 ffmpeg_cmd = [ - "ffmpeg", "-y", "-f", "x11grab", - "-video_size", f"{display_width}x{display_height}", - "-i", display, - "-c:v", "libx264", "-preset", "ultrafast", - "-maxrate", "700k", "-bufsize", "1400k", "-crf", "36", - "-pix_fmt", "yuv420p", "-tune", "animation", - "-x264-params", "bframes=0:deblock=-1,-1", - "-r", "15", "-an", + "ffmpeg", + "-y", + "-f", + "x11grab", + "-video_size", + f"{display_width}x{display_height}", + "-i", + display, + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-maxrate", + "700k", + "-bufsize", + "1400k", + "-crf", + "36", + "-pix_fmt", + "yuv420p", + "-tune", + "animation", + "-x264-params", + "bframes=0:deblock=-1,-1", + "-r", + "15", + "-an", output_file.as_posix(), - "-nostats", "-loglevel", "0" + "-nostats", + "-loglevel", + "0", ] logger.debug("Starting FFmpeg recording to %s", output_file) logger.debug_trace(f"FFmpeg command: {' '.join(ffmpeg_cmd)}") @@ -920,9 +967,9 @@ def _start_ffmpeg_recording(display: str) -> None: def _stop_ffmpeg_recording() -> None: """Stop FFmpeg screen recording if running.""" import signal + global DISPLAY proc = DISPLAY.get("ffmpeg") - output_file = DISPLAY.get("ffmpeg_output") if not proc: return if proc.poll() is not None: @@ -935,21 +982,17 @@ def _stop_ffmpeg_recording() -> None: proc.wait(timeout=5) logger.debug("Stopped ffmpeg recording") except Exception as e: - logger.debug(f"ffmpeg stop: {e}") - try: + logger.debug("ffmpeg stop: %s", e) + with suppress(Exception): proc.terminate() proc.wait(timeout=2) - except Exception: - pass - try: + with suppress(Exception): proc.kill() - except Exception: - pass DISPLAY["ffmpeg"] = None DISPLAY["ffmpeg_output"] = None -def _try_with_cached_cookies(url: str, hostname: str) -> Optional[str]: +def _try_with_cached_cookies(url: str, hostname: str) -> str | None: """Attempt request with cached cookies before using Chrome.""" cookies = get_cf_cookies_for_domain(hostname) if not cookies: @@ -959,10 +1002,17 @@ def _try_with_cached_cookies(url: str, hostname: str) -> Optional[str]: headers = {} stored_ua = get_cf_user_agent_for_domain(hostname) if stored_ua: - headers['User-Agent'] = stored_ua + headers["User-Agent"] = stored_ua - logger.debug(f"Trying request with cached cookies: {url}") - response = requests.get(url, cookies=cookies, headers=headers, proxies=get_proxies(url), timeout=(5, 10), verify=get_ssl_verify(url)) + logger.debug("Trying request with cached cookies: %s", url) + response = requests.get( + url, + cookies=cookies, + headers=headers, + proxies=get_proxies(url), + timeout=(5, 10), + verify=get_ssl_verify(url), + ) if response.status_code == 200: logger.debug("Cached cookies worked, skipped Chrome bypass") return response.text @@ -973,10 +1023,8 @@ def _try_with_cached_cookies(url: str, hostname: str) -> Optional[str]: def get_bypassed_page( - url: str, - selector: Optional[network.AAMirrorSelector] = None, - cancel_flag: Optional[Event] = None -) -> Optional[str]: + url: str, selector: network.AAMirrorSelector | None = None, cancel_flag: Event | None = None +) -> str | None: """Fetch HTML content from a URL using the internal Cloudflare Bypasser.""" sel = selector or network.AAMirrorSelector() attempt_url = sel.rewrite(url) diff --git a/shelfmark/config/booklore_settings.py b/shelfmark/config/booklore_settings.py index c7d864f2..a823eae9 100644 --- a/shelfmark/config/booklore_settings.py +++ b/shelfmark/config/booklore_settings.py @@ -130,12 +130,13 @@ def get_booklore_library_options() -> list[dict[str, Any]]: try: library_options, _ = _get_booklore_cached_options(base_url, username, password) - return library_options - except Exception as exc: - logger.error(f"Failed to fetch Booklore libraries: {exc}") + except Exception: + logger.exception("Failed to fetch Booklore libraries") if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key: return _BOOKLORE_OPTIONS_CACHE.get("library_options", []) return [] + else: + return library_options def get_booklore_path_options() -> list[dict[str, Any]]: @@ -154,19 +155,22 @@ def get_booklore_path_options() -> list[dict[str, Any]]: try: _, path_options = _get_booklore_cached_options(base_url, username, password) - return path_options - except Exception as exc: - logger.error(f"Failed to fetch Booklore paths: {exc}") + except Exception: + logger.exception("Failed to fetch Booklore paths") if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key: return _BOOKLORE_OPTIONS_CACHE.get("path_options", []) return [] + else: + return path_options -def test_booklore_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: +def test_booklore_connection( + current_values: dict[str, Any] | None = None, +) -> dict[str, Any]: """Test the Booklore connection using current form values.""" current_values = current_values or {} - def _get_value(key: str, default: Any = None) -> Any: + def _get_value(key: str, default: object = None) -> object: value = current_values.get(key) if value not in (None, ""): return value @@ -187,11 +191,11 @@ def test_booklore_connection(current_values: dict[str, Any] | None = None) -> di try: library_options, _ = _get_booklore_select_options(base_url, username, password) - + except BookloreError as exc: + return {"success": False, "message": str(exc)} + else: message = "Connected to Grimmory" if library_options: message = f"Connected to Grimmory ({len(library_options)} libraries)" return {"success": True, "message": message} - except BookloreError as exc: - return {"success": False, "message": str(exc)} diff --git a/shelfmark/config/email_settings.py b/shelfmark/config/email_settings.py index 82a13e62..8753220b 100644 --- a/shelfmark/config/email_settings.py +++ b/shelfmark/config/email_settings.py @@ -3,15 +3,20 @@ from __future__ import annotations from typing import Any from shelfmark.core.config import config -from shelfmark.download.outputs.email import EmailOutputError, build_email_smtp_config, test_smtp_connection +from shelfmark.download.outputs.email import ( + EmailOutputError, + build_email_smtp_config, + test_smtp_connection, +) -def test_email_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: +def test_email_connection( + current_values: dict[str, Any] | None = None, +) -> dict[str, Any]: """Test SMTP connectivity using current form values (including unsaved changes).""" - current_values = current_values or {} - def _get_value(key: str, default: Any = None) -> Any: + def _get_value(key: str, default: object = None) -> object: value = current_values.get(key) if value not in (None, ""): return value @@ -28,15 +33,15 @@ def test_email_connection(current_values: dict[str, Any] | None = None) -> dict[ "EMAIL_FROM": _get_value("EMAIL_FROM", ""), "EMAIL_SUBJECT_TEMPLATE": _get_value("EMAIL_SUBJECT_TEMPLATE", "{Title}"), "EMAIL_SMTP_TIMEOUT_SECONDS": _get_value("EMAIL_SMTP_TIMEOUT_SECONDS", 60), - "EMAIL_ALLOW_UNVERIFIED_TLS": _get_value("EMAIL_ALLOW_UNVERIFIED_TLS", False), + "EMAIL_ALLOW_UNVERIFIED_TLS": _get_value("EMAIL_ALLOW_UNVERIFIED_TLS", default=False), } try: smtp_config = build_email_smtp_config(settings) test_smtp_connection(smtp_config) - return {"success": True, "message": "Connected to SMTP server"} except EmailOutputError as exc: return {"success": False, "message": str(exc)} except Exception as exc: return {"success": False, "message": f"SMTP test failed: {exc}"} - + else: + return {"success": True, "message": "Connected to SMTP server"} diff --git a/shelfmark/config/env.py b/shelfmark/config/env.py index 6d488c29..ed131362 100644 --- a/shelfmark/config/env.py +++ b/shelfmark/config/env.py @@ -23,11 +23,11 @@ def _read_debug_from_config() -> bool: if config_file.exists(): try: - with open(config_file, "r") as f: + with config_file.open() as f: config = json.load(f) if "DEBUG" in config: return bool(config["DEBUG"]) - except (json.JSONDecodeError, OSError): + except json.JSONDecodeError, OSError: pass return False @@ -36,10 +36,10 @@ def _read_debug_from_config() -> bool: def _is_sqlite_file(path: Path) -> bool: """Check if a file is a valid SQLite database by reading magic bytes.""" try: - with open(path, "rb") as f: + with path.open("rb") as f: header = f.read(16) return header[:16] == b"SQLite format 3\x00" - except (OSError, PermissionError): + except OSError, PermissionError: return False @@ -67,14 +67,16 @@ def _is_config_dir_writable() -> bool: test_file = CONFIG_DIR / ".write_test" test_file.touch() test_file.unlink() - return True - except (OSError, PermissionError): + except OSError, PermissionError: return False + else: + return True def is_covers_cache_enabled() -> bool: """Check if cover caching is enabled (requires setting + writable config dir).""" from shelfmark.core.config import config + setting_enabled = config.get("COVERS_CACHE_ENABLED", True) return setting_enabled and _is_config_dir_writable() @@ -151,7 +153,7 @@ ONBOARDING = string_to_bool(os.getenv("ONBOARDING", "true")) # Debug: skip specific download sources for testing fallback chains # Comma-separated values: aa-fast, aa-slow-nowait, aa-slow-wait, libgen, zlib, welib _DEBUG_SKIP_SOURCES_RAW = os.getenv("DEBUG_SKIP_SOURCES", "").strip().lower() -DEBUG_SKIP_SOURCES = set(s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip()) +DEBUG_SKIP_SOURCES = {s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip()} # ============================================================================= diff --git a/shelfmark/config/migrations.py b/shelfmark/config/migrations.py index 8e78d427..ede29057 100644 --- a/shelfmark/config/migrations.py +++ b/shelfmark/config/migrations.py @@ -1,8 +1,11 @@ """Configuration migration helpers.""" import json -from typing import Any, Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from collections.abc import Callable _DEPRECATED_SETTINGS_RESTRICTION_KEYS = ( "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", @@ -11,7 +14,7 @@ _DEPRECATED_SETTINGS_RESTRICTION_KEYS = ( ) -def _as_bool(value: Any) -> bool: +def _as_bool(value: object) -> bool: if isinstance(value, bool): return value if isinstance(value, str): @@ -23,10 +26,7 @@ def _pick_legacy_settings_restriction(config: dict[str, Any]) -> bool | None: """Pick the best legacy admin-restriction value to migrate.""" auth_method = str(config.get("AUTH_METHOD", "")).strip().lower() - if ( - auth_method == "proxy" - and "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config - ): + if auth_method == "proxy" and "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config: return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN")) if auth_method == "cwa" and "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config: @@ -50,9 +50,9 @@ def migrate_security_settings( load_users_config: Callable[[], dict[str, Any]], save_users_config: Callable[[dict[str, Any]], None], ensure_config_dir: Callable[[], None], - get_config_path: Callable[[], Any], + get_config_path: Callable[[], object], sync_builtin_admin_user: Callable[[str, str], None], - logger: Any, + logger: object, ) -> None: """Migrate legacy security keys and sync builtin admin credentials.""" try: @@ -67,13 +67,12 @@ def migrate_security_settings( if old_value: config["AUTH_METHOD"] = "cwa" logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'") + elif config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"): + config["AUTH_METHOD"] = "builtin" + logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'") else: - if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"): - config["AUTH_METHOD"] = "builtin" - logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'") - else: - config["AUTH_METHOD"] = "none" - logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'") + config["AUTH_METHOD"] = "none" + logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'") migrated_security = True else: logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)") @@ -82,14 +81,17 @@ def migrate_security_settings( # Backfill AUTH_METHOD for configs that have builtin credentials but # were never migrated from USE_CWA_AUTH (e.g. dev builds that predated # the AUTH_METHOD field). - if "AUTH_METHOD" not in config: - if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"): - config["AUTH_METHOD"] = "builtin" - migrated_security = True - logger.info( - "Backfilled AUTH_METHOD='builtin' from legacy " - "BUILTIN_USERNAME/BUILTIN_PASSWORD_HASH credentials" - ) + if ( + "AUTH_METHOD" not in config + and config.get("BUILTIN_USERNAME") + and config.get("BUILTIN_PASSWORD_HASH") + ): + config["AUTH_METHOD"] = "builtin" + migrated_security = True + logger.info( + "Backfilled AUTH_METHOD='builtin' from legacy " + "BUILTIN_USERNAME/BUILTIN_PASSWORD_HASH credentials" + ) if "RESTRICT_SETTINGS_TO_ADMIN" not in users_config: legacy_restrict = _pick_legacy_settings_restriction(config) @@ -97,31 +99,30 @@ def migrate_security_settings( save_users_config({"RESTRICT_SETTINGS_TO_ADMIN": legacy_restrict}) migrated_users = True logger.info( - "Migrated legacy settings-admin restriction to users.RESTRICT_SETTINGS_TO_ADMIN=" - f"{legacy_restrict}" + "Migrated legacy settings-admin restriction to users.RESTRICT_SETTINGS_TO_ADMIN=%s", + legacy_restrict, ) for deprecated_key in _DEPRECATED_SETTINGS_RESTRICTION_KEYS: if deprecated_key in config: config.pop(deprecated_key, None) migrated_security = True - logger.info(f"Removed deprecated security setting: {deprecated_key}") + logger.info("Removed deprecated security setting: %s", deprecated_key) try: sync_builtin_admin_user( config.get("BUILTIN_USERNAME", ""), config.get("BUILTIN_PASSWORD_HASH", ""), ) - except Exception as exc: - logger.error( - "Failed to sync builtin credentials to users database during migration: " - f"{exc}" + except Exception: + logger.exception( + "Failed to sync builtin credentials to users database during migration" ) if migrated_security: ensure_config_dir() - config_path = get_config_path() - with open(config_path, "w") as f: + config_path = Path(get_config_path()) + with config_path.open("w") as f: json.dump(config, f, indent=2) logger.info("Security settings migration completed successfully") elif migrated_users: @@ -131,5 +132,5 @@ def migrate_security_settings( except FileNotFoundError: logger.debug("No existing security config file found - nothing to migrate") - except Exception as exc: - logger.error(f"Failed to migrate security settings: {exc}") + except Exception: + logger.exception("Failed to migrate security settings") diff --git a/shelfmark/config/notifications_settings.py b/shelfmark/config/notifications_settings.py index cd2d2445..20edd733 100644 --- a/shelfmark/config/notifications_settings.py +++ b/shelfmark/config/notifications_settings.py @@ -11,6 +11,7 @@ from shelfmark.core.notifications import NotificationEvent, send_test_notificati from shelfmark.core.settings_registry import ( ActionButton, HeadingField, + SettingsField, TableField, load_config_file, register_on_save, @@ -124,7 +125,7 @@ def _count_invalid_route_urls(routes: list[dict[str, Any]]) -> int: def _ensure_default_route_row(routes: list[dict[str, Any]]) -> list[dict[str, Any]]: - return routes if routes else [dict(row) for row in _DEFAULT_ROUTE_ROWS] + return routes or [dict(row) for row in _DEFAULT_ROUTE_ROWS] def _extract_unique_route_urls(routes: list[dict[str, Any]]) -> list[str]: @@ -260,7 +261,7 @@ register_on_save("notifications", _on_save_notifications) @register_settings("notifications", "Notifications", icon="bell", order=7) -def notifications_settings(): +def notifications_settings() -> list[SettingsField]: """Global notifications settings.""" return [ HeadingField( diff --git a/shelfmark/config/security.py b/shelfmark/config/security.py index 95daff11..36d2cc9a 100644 --- a/shelfmark/config/security.py +++ b/shelfmark/config/security.py @@ -1,6 +1,6 @@ """Authentication settings registration.""" -from typing import Any, Dict, Callable +from typing import TYPE_CHECKING, Any from shelfmark.config.migrations import migrate_security_settings from shelfmark.config.security_handlers import ( @@ -10,19 +10,23 @@ from shelfmark.config.security_handlers import ( from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger from shelfmark.core.settings_registry import ( - register_settings, - register_on_save, - load_config_file, - TextField, - SelectField, - PasswordField, - CheckboxField, ActionButton, - TagListField, + CheckboxField, CustomComponentField, + PasswordField, + SelectField, + SettingsField, + TagListField, + TextField, + load_config_file, + register_on_save, + register_settings, ) from shelfmark.core.user_db import sync_builtin_admin_user +if TYPE_CHECKING: + from collections.abc import Callable + logger = setup_logger(__name__) @@ -36,8 +40,8 @@ def _auth_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> def _migrate_security_settings() -> None: from shelfmark.core.settings_registry import ( - _get_config_file_path, _ensure_config_dir, + _get_config_file_path, save_config_file, ) @@ -52,12 +56,11 @@ def _migrate_security_settings() -> None: ) - -def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]: +def _on_save_security(values: dict[str, Any]) -> dict[str, Any]: return on_save_security(values) -def _test_oidc_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: +def _test_oidc_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: return test_oidc_connection( load_security_config=lambda: { "OIDC_DISCOVERY_URL": app_config.get("OIDC_DISCOVERY_URL", ""), @@ -68,7 +71,7 @@ def _test_oidc_connection(current_values: Dict[str, Any] = None) -> Dict[str, An @register_settings("security", "Security", icon="shield", order=5) -def security_settings(): +def security_settings() -> list[SettingsField]: """Security and authentication settings.""" from shelfmark.config.env import CWA_DB_PATH @@ -105,18 +108,22 @@ def security_settings(): label="A local admin account is required before OIDC can be enabled.", show_when=_auth_condition("oidc"), ), - *([] if cwa_db_available else [ - CustomComponentField( - key="cwa_db_missing", - component="oidc_admin_hint", - label=( - "Calibre-Web database not detected. Mount your app.db to " - "/auth/app.db to enable this method. Authentication will fall " - "back to none until the database is available." + *( + [] + if cwa_db_available + else [ + CustomComponentField( + key="cwa_db_missing", + component="oidc_admin_hint", + label=( + "Calibre-Web database not detected. Mount your app.db to " + "/auth/app.db to enable this method. Authentication will fall " + "back to none until the database is available." + ), + show_when=_auth_condition("cwa"), ), - show_when=_auth_condition("cwa"), - ), - ]), + ] + ), ActionButton( key="open_users_tab", label="Go to Users", diff --git a/shelfmark/config/security_handlers.py b/shelfmark/config/security_handlers.py index 69ef82c5..660485d5 100644 --- a/shelfmark/config/security_handlers.py +++ b/shelfmark/config/security_handlers.py @@ -1,21 +1,26 @@ """Operational handlers for security settings (save/actions).""" import os -from typing import Any, Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any -from shelfmark.core.utils import normalize_http_url from shelfmark.core.user_db import UserDB +from shelfmark.core.utils import normalize_http_url from shelfmark.download.network import get_ssl_verify +if TYPE_CHECKING: + from collections.abc import Callable _OIDC_LOCKOUT_MESSAGE = "A local admin account with a password is required before enabling OIDC. Use the 'Go to Users' button above to create one. This ensures you can still sign in if your identity provider is unavailable." def _has_local_password_admin() -> bool: root = os.environ.get("CONFIG_DIR", "/config") - user_db = UserDB(os.path.join(root, "users.db")) + user_db = UserDB(str(Path(root) / "users.db")) user_db.initialize() - return any(user.get("password_hash") and user.get("role") == "admin" for user in user_db.list_users()) + return any( + user.get("password_hash") and user.get("role") == "admin" for user in user_db.list_users() + ) def on_save_security( @@ -56,7 +61,9 @@ def test_oidc_connection( try: # Prefer the current (unsaved) form value over the saved config - discovery_url = (current_values or {}).get("OIDC_DISCOVERY_URL") or load_security_config().get("OIDC_DISCOVERY_URL", "") + discovery_url = (current_values or {}).get( + "OIDC_DISCOVERY_URL" + ) or load_security_config().get("OIDC_DISCOVERY_URL", "") if not discovery_url: return {"success": False, "message": "Discovery URL is not configured."} @@ -67,9 +74,12 @@ def test_oidc_connection( required_fields = ["issuer", "authorization_endpoint", "token_endpoint"] missing_fields = [field for field in required_fields if field not in document] if missing_fields: - return {"success": False, "message": f"Discovery document missing fields: {', '.join(missing_fields)}"} + return { + "success": False, + "message": f"Discovery document missing fields: {', '.join(missing_fields)}", + } return {"success": True, "message": f"Connected to {document['issuer']}"} except Exception as exc: - logger.error(f"OIDC connection test failed: {exc}") - return {"success": False, "message": f"Connection failed: {str(exc)}"} + logger.exception("OIDC connection test failed") + return {"success": False, "message": f"Connection failed: {exc!s}"} diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index e856b781..b94371d5 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -1,12 +1,11 @@ """Core settings registration and derived configuration values.""" -import os -from pathlib import Path import json -from typing import Any, Dict +from pathlib import Path +from typing import Any -def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]: +def _on_save_advanced(values: dict[str, Any]) -> dict[str, Any]: """Validate advanced settings before persisting.""" from shelfmark.core.logger import setup_logger @@ -37,7 +36,7 @@ def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]: if not host or not remote_path or not local_path: logger.debug( - "Skipping entry %d: missing field(s) - host=%r, remotePath=%r, localPath=%r", + "Skipping entry %d: missing field(s) - host=%s, remotePath=%s, localPath=%s", i, host, remote_path, @@ -57,7 +56,9 @@ def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]: logger.info("Saved %d remote path mapping(s)", len(cleaned)) if cleaned: for m in cleaned: - logger.debug(" Mapping: %s -> %s (client: %s)", m["remotePath"], m["localPath"], m["host"]) + logger.debug( + " Mapping: %s -> %s (client: %s)", m["remotePath"], m["localPath"], m["host"] + ) values["PROWLARR_REMOTE_PATH_MAPPINGS"] = cleaned return {"error": False, "values": values} @@ -76,19 +77,19 @@ logger = setup_logger(__name__) # Log bootstrap configuration values at DEBUG level logger.debug("Bootstrap configuration:") -for key in ['CONFIG_DIR', 'LOG_DIR', 'TMP_DIR', 'INGEST_DIR', 'DEBUG', 'DOCKERMODE']: +for key in ["CONFIG_DIR", "LOG_DIR", "TMP_DIR", "INGEST_DIR", "DEBUG", "DOCKERMODE"]: if hasattr(env, key): - logger.debug(f" {key}: {getattr(env, key)}") + logger.debug(" %s: %s", key, getattr(env, key)) # Load supported book languages from data file # Path is relative to the package root, not this file _DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" -with open(_DATA_DIR / "book-languages.json") as file: +with (_DATA_DIR / "book-languages.json").open() as file: _SUPPORTED_BOOK_LANGUAGE = json.load(file) # Directory settings BASE_DIR = Path(__file__).resolve().parent.parent.parent -logger.debug(f"BASE_DIR: {BASE_DIR}") +logger.debug("BASE_DIR: %s", BASE_DIR) if env.ENABLE_LOGGING: env.LOG_DIR.mkdir(exist_ok=True) @@ -106,6 +107,7 @@ RECORDING_DIR = env.LOG_DIR / "recording" def _log_external_bypasser_warning() -> None: """Log warning about external bypasser DNS limitations (called after config is available).""" from shelfmark.core.config import config + if config.get("USING_EXTERNAL_BYPASSER", False) and config.get("USE_CF_BYPASS", True): logger.warning( "Using external bypasser (FlareSolverr). Note: FlareSolverr uses its own DNS resolution, " @@ -116,36 +118,31 @@ def _log_external_bypasser_warning() -> None: from shelfmark.core.settings_registry import ( - register_settings, + ActionButton, + CheckboxField, + HeadingField, + MultiSelectField, + NumberField, + OrderableListField, + PasswordField, + SelectField, + SettingsField, + TableField, + TagListField, + TextField, + load_config_file, register_group, register_on_save, - load_config_file, - TextField, - PasswordField, - NumberField, - CheckboxField, - SelectField, - MultiSelectField, - TagListField, - OrderableListField, - TableField, - HeadingField, - ActionButton, + register_settings, ) - -register_group( - "direct_download", - "Direct Download", - icon="download", - order=20 -) +register_group("direct_download", "Direct Download", icon="download", order=20) register_group( "metadata_providers", "Metadata Providers", icon="book", - order=12 # Between Network (10) and Advanced (15) + order=12, # Between Network (10) and Advanced (15) ) @@ -203,15 +200,15 @@ _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES = { } -def _get_metadata_provider_options(): +def _get_metadata_provider_options() -> list[dict[str, str]]: """Build metadata provider options dynamically from enabled providers only.""" - from shelfmark.metadata_providers import list_providers, is_provider_enabled + from shelfmark.metadata_providers import is_provider_enabled, list_providers - options = [] - for provider in list_providers(): - # Only show providers that are enabled - if is_provider_enabled(provider["name"]): - options.append({"value": provider["name"], "label": provider["display_name"]}) + options = [ + {"value": provider["name"], "label": provider["display_name"]} + for provider in list_providers() + if is_provider_enabled(provider["name"]) + ] # If no providers enabled, show a placeholder option if not options: @@ -222,12 +219,12 @@ def _get_metadata_provider_options(): return options -def _get_metadata_provider_options_with_none(): +def _get_metadata_provider_options_with_none() -> list[dict[str, str]]: """Build metadata provider options with a 'Use main provider' option first.""" - return [{"value": "", "label": "Use book provider"}] + _get_metadata_provider_options() + return [{"value": "", "label": "Use book provider"}, *_get_metadata_provider_options()] -def _get_release_source_options_for_content_type(content_type: str): +def _get_release_source_options_for_content_type(content_type: str) -> list[dict[str, str]]: """Build release source options dynamically for a specific content type.""" from shelfmark.release_sources import list_available_sources @@ -239,25 +236,28 @@ def _get_release_source_options_for_content_type(content_type: str): ] -def _get_book_release_source_options(): +def _get_book_release_source_options() -> list[dict[str, str]]: """Build default release source options for book searches.""" return _get_release_source_options_for_content_type("ebook") -def _get_audiobook_release_source_options(): +def _get_audiobook_release_source_options() -> list[dict[str, str]]: """Build default release source options for audiobook searches.""" - return [{"value": "", "label": "Use book release source"}] + _get_release_source_options_for_content_type( - "audiobook" - ) + return [ + {"value": "", "label": "Use book release source"}, + *_get_release_source_options_for_content_type("audiobook"), + ] +_LANGUAGE_OPTIONS = [ + {"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE +] -_LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE] -def _get_aa_base_url_options(): +def _get_aa_base_url_options() -> list[dict[str, str]]: """Build AA URL options dynamically, including additional mirrors from config.""" - from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS, get_aa_mirrors from shelfmark.core.config import config + from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS, get_aa_mirrors from shelfmark.core.utils import normalize_http_url options = [{"value": "auto", "label": "Auto (Recommended)"}] @@ -273,7 +273,7 @@ def _get_aa_base_url_options(): allow_special=("auto",), ) if configured_url and configured_url != "auto" and configured_url not in all_mirrors: - all_mirrors = [configured_url] + all_mirrors + all_mirrors = [configured_url, *all_mirrors] for url in all_mirrors: domain = url.replace("https://", "").replace("http://", "") @@ -286,10 +286,10 @@ def _get_aa_base_url_options(): return options -def _get_zlib_mirror_options(): +def _get_zlib_mirror_options() -> list[dict[str, str]]: """Build Z-Library mirror options for SelectField.""" - from shelfmark.core.mirrors import DEFAULT_ZLIB_MIRRORS from shelfmark.core.config import config + from shelfmark.core.mirrors import DEFAULT_ZLIB_MIRRORS options = [] @@ -310,10 +310,10 @@ def _get_zlib_mirror_options(): return options -def _get_welib_mirror_options(): +def _get_welib_mirror_options() -> list[dict[str, str]]: """Build Welib mirror options for SelectField.""" - from shelfmark.core.mirrors import DEFAULT_WELIB_MIRRORS from shelfmark.core.config import config + from shelfmark.core.mirrors import DEFAULT_WELIB_MIRRORS options = [] @@ -345,16 +345,17 @@ def _clear_covers_cache(current_values: dict) -> dict: # Reset the singleton so it reinitializes with fresh state reset_image_cache() + except Exception as e: + logger.exception("Failed to clear cover cache") + return { + "success": False, + "message": f"Failed to clear cache: {e!s}", + } + else: return { "success": True, "message": f"Cleared {count} cached cover images.", } - except Exception as e: - logger.error(f"Failed to clear cover cache: {e}") - return { - "success": False, - "message": f"Failed to clear cache: {str(e)}", - } def _clear_metadata_cache(current_values: dict) -> dict: @@ -371,15 +372,15 @@ def _clear_metadata_cache(current_values: dict) -> dict: "message": f"Cleared {stats_before['size']} cached entries.", } except Exception as e: - logger.error(f"Failed to clear metadata cache: {e}") + logger.exception("Failed to clear metadata cache") return { "success": False, - "message": f"Failed to clear cache: {str(e)}", + "message": f"Failed to clear cache: {e!s}", } @register_settings("general", "General", icon="settings", order=0) -def general_settings(): +def general_settings() -> list[SettingsField]: """Core application settings.""" return [ TextField( @@ -424,7 +425,7 @@ def general_settings(): @register_settings("search_mode", "Search Mode", icon="search", order=1) -def search_mode_settings(): +def search_mode_settings() -> list[SettingsField]: """Configure how you search for and download books.""" return [ HeadingField( @@ -531,10 +532,9 @@ def search_mode_settings(): @register_settings("network", "Network", icon="globe", order=10) -def network_settings(): +def network_settings() -> list[SettingsField]: """Network and connectivity settings.""" - # Check if Tor variant is available and if Tor is currently enabled - tor_available = env.TOR_VARIANT_AVAILABLE + # Check if Tor is currently enabled. tor_enabled = env.USING_TOR # When Tor is enabled, DNS/proxy settings are overridden by iptables rules @@ -595,7 +595,10 @@ def network_settings(): disabled=tor_overrides_network, disabled_reason="DNS over HTTPS is not used when Tor routing is enabled.", # Hide for manual and system (no DoH endpoint available for custom IPs or system DNS) - show_when={"field": "CUSTOM_DNS", "value": ["auto", "google", "cloudflare", "quad9", "opendns"]}, + show_when={ + "field": "CUSTOM_DNS", + "value": ["auto", "google", "cloudflare", "quad9", "opendns"], + }, # Disable for auto (always uses DoH) disabled_when={ "field": "CUSTOM_DNS", @@ -690,9 +693,7 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]: normalized_content_types: list[str] = [] elif isinstance(raw_content_types, list): normalized_content_types = [ - str(value).strip().lower() - for value in raw_content_types - if str(value).strip() + str(value).strip().lower() for value in raw_content_types if str(value).strip() ] else: return { @@ -773,28 +774,44 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]: try: port = int(effective.get("EMAIL_SMTP_PORT", 587)) - except (TypeError, ValueError): + except TypeError, ValueError: return {"error": True, "message": "SMTP port must be a number", "values": values} if port < 1 or port > 65535: - return {"error": True, "message": "SMTP port must be between 1 and 65535", "values": values} + return { + "error": True, + "message": "SMTP port must be between 1 and 65535", + "values": values, + } try: timeout_seconds = int(effective.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60)) - except (TypeError, ValueError): - return {"error": True, "message": "SMTP timeout (seconds) must be a number", "values": values} + except TypeError, ValueError: + return { + "error": True, + "message": "SMTP timeout (seconds) must be a number", + "values": values, + } if timeout_seconds < 1: - return {"error": True, "message": "SMTP timeout (seconds) must be >= 1", "values": values} + return { + "error": True, + "message": "SMTP timeout (seconds) must be >= 1", + "values": values, + } username = str(effective.get("EMAIL_SMTP_USERNAME", "") or "").strip() password = effective.get("EMAIL_SMTP_PASSWORD", "") or "" if username and not password: - return {"error": True, "message": "SMTP password is required when username is set", "values": values} + return { + "error": True, + "message": "SMTP password is required when username is set", + "values": values, + } try: attachment_limit_mb = int(effective.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25)) - except (TypeError, ValueError): + except TypeError, ValueError: return { "error": True, "message": "Attachment size limit (MB) must be a number", @@ -846,7 +863,7 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]: @register_settings("downloads", "Downloads", icon="folder", order=5) -def download_settings(): +def download_settings() -> list[SettingsField]: """Configure download behavior and file locations.""" return [ # === BOOKS SECTION === @@ -901,17 +918,17 @@ def download_settings(): { "value": "none", "label": "None", - "description": "Keep original filename from source" + "description": "Keep original filename from source", }, { "value": "rename", "label": "Rename Only", - "description": "Rename single-file downloads; multi-file keeps original names." + "description": "Rename single-file downloads; multi-file keeps original names.", }, { "value": "organize", "label": "Rename and Organize", - "description": "Create folders and rename files using a template. Do not use with ingest folders." + "description": "Create folders and rename files using a template. Do not use with ingest folders.", }, ], default="rename", @@ -1081,7 +1098,11 @@ def download_settings(): description="Transport security mode for SMTP.", options=[ {"value": "none", "label": "None", "description": "No TLS (not recommended)."}, - {"value": "starttls", "label": "STARTTLS", "description": "Upgrade to TLS after connecting (recommended)."}, + { + "value": "starttls", + "label": "STARTTLS", + "description": "Upgrade to TLS after connecting (recommended).", + }, {"value": "ssl", "label": "SSL/TLS", "description": "Connect using TLS (SMTPS)."}, ], default="starttls", @@ -1139,7 +1160,6 @@ def download_settings(): callback=test_email_connection, show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, ), - # === AUDIOBOOKS SECTION === # Universal mode only HeadingField( @@ -1160,9 +1180,21 @@ def download_settings(): label="File Organization", description="Choose how downloaded audiobook files are named and organized.", options=[ - {"value": "none", "label": "None", "description": "Keep original filename from source"}, - {"value": "rename", "label": "Rename Only", "description": "Rename single-file downloads; multi-file keeps original names."}, - {"value": "organize", "label": "Rename and Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders."}, + { + "value": "none", + "label": "None", + "description": "Keep original filename from source", + }, + { + "value": "rename", + "label": "Rename Only", + "description": "Rename single-file downloads; multi-file keeps original names.", + }, + { + "value": "organize", + "label": "Rename and Organize", + "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders.", + }, ], default="rename", universal_only=True, @@ -1194,7 +1226,6 @@ def download_settings(): default=True, universal_only=True, ), - # === OPTIONS SECTION === HeadingField( key="options_heading", @@ -1239,7 +1270,7 @@ def download_settings(): register_on_save("downloads", _on_save_downloads) -def _get_fast_source_options(): +def _get_fast_source_options() -> list[dict[str, str | bool | int | None]]: """Fast download sources - configurable list shown in settings.""" from shelfmark.core.config import config @@ -1263,7 +1294,7 @@ def _get_fast_source_options(): ] -def _get_fast_source_defaults(): +def _get_fast_source_defaults() -> list[dict[str, str | bool]]: """Default values for fast sources display.""" return [ {"id": "aa-fast", "enabled": True}, @@ -1271,7 +1302,7 @@ def _get_fast_source_defaults(): ] -def _get_slow_source_options(): +def _get_slow_source_options() -> list[dict[str, str | bool | None]]: """Slow download sources - configurable order. All require bypasser.""" from shelfmark.core.config import config @@ -1311,7 +1342,7 @@ def _get_slow_source_options(): ] -def _get_slow_source_defaults(): +def _get_slow_source_defaults() -> list[dict[str, str | bool]]: """Default source priority order for slow sources.""" from shelfmark.config.env import _LEGACY_ALLOW_USE_WELIB @@ -1323,8 +1354,10 @@ def _get_slow_source_defaults(): ] -@register_settings("download_sources", "Download Sources", icon="download", order=21, group="direct_download") -def download_source_settings(): +@register_settings( + "download_sources", "Download Sources", icon="download", order=21, group="direct_download" +) +def download_source_settings() -> list[SettingsField]: """Settings for download source behavior.""" return [ PasswordField( @@ -1429,8 +1462,10 @@ def download_source_settings(): ] -@register_settings("cloudflare_bypass", "Cloudflare Bypass", icon="shield", order=22, group="direct_download") -def cloudflare_bypass_settings(): +@register_settings( + "cloudflare_bypass", "Cloudflare Bypass", icon="shield", order=22, group="direct_download" +) +def cloudflare_bypass_settings() -> list[SettingsField]: """Settings for Cloudflare bypass behavior.""" return [ CheckboxField( @@ -1477,7 +1512,8 @@ def cloudflare_bypass_settings(): ), ] -def _on_save_mirrors(values: Dict[str, Any]) -> Dict[str, Any]: + +def _on_save_mirrors(values: dict[str, Any]) -> dict[str, Any]: """Normalize mirror list settings before persisting.""" from shelfmark.core.logger import setup_logger from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS @@ -1512,14 +1548,19 @@ def _on_save_mirrors(values: Dict[str, Any]) -> Dict[str, Any]: values["AA_MIRROR_URLS"] = normalized return {"error": False, "values": values} + # Register the on_save handler for this tab register_on_save("mirrors", _on_save_mirrors) @register_settings("mirrors", "Mirrors", icon="globe", order=23, group="direct_download") -def mirror_settings(): +def mirror_settings() -> list[SettingsField]: """Configure download source mirrors.""" - from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS, DEFAULT_ZLIB_MIRRORS, DEFAULT_WELIB_MIRRORS + from shelfmark.core.mirrors import ( + DEFAULT_AA_MIRRORS, + DEFAULT_WELIB_MIRRORS, + DEFAULT_ZLIB_MIRRORS, + ) return [ # === PRIMARY SOURCE === @@ -1548,7 +1589,6 @@ def mirror_settings(): description="Deprecated. Use Mirrors instead. This is kept for backwards compatibility with existing installs and environment variables.", show_when={"field": "AA_ADDITIONAL_URLS", "notEmpty": True}, ), - # === LIBGEN === HeadingField( key="libgen_mirrors_heading", @@ -1560,7 +1600,6 @@ def mirror_settings(): label="Additional Mirrors", description="Comma-separated list of custom LibGen mirrors to add to the defaults.", ), - # === Z-LIBRARY === HeadingField( key="zlib_mirrors_heading", @@ -1579,7 +1618,6 @@ def mirror_settings(): label="Additional Mirrors", description="Comma-separated list of custom Z-Library mirror URLs.", ), - # === WELIB === HeadingField( key="welib_mirrors_heading", @@ -1602,7 +1640,7 @@ def mirror_settings(): @register_settings("advanced", "Advanced", icon="cog", order=15) -def advanced_settings(): +def advanced_settings() -> list[SettingsField]: """Advanced settings for power users.""" return [ TextField( @@ -1648,8 +1686,16 @@ def advanced_settings(): label="Custom Script Path Mode", description="Pass the path to the custom script as an absolute path or relative to the destination folder.", options=[ - {"value": "absolute", "label": "Absolute", "description": "Pass the full destination path (default)."}, - {"value": "relative", "label": "Relative", "description": "Pass the path relative to the destination folder."}, + { + "value": "absolute", + "label": "Absolute", + "description": "Pass the full destination path (default).", + }, + { + "value": "relative", + "label": "Relative", + "description": "Pass the path relative to the destination folder.", + }, ], default="absolute", ), diff --git a/shelfmark/config/users_settings.py b/shelfmark/config/users_settings.py index 6fc96168..dab1a5ec 100644 --- a/shelfmark/config/users_settings.py +++ b/shelfmark/config/users_settings.py @@ -7,6 +7,11 @@ that talks to /api/admin/users endpoints. from typing import Any +from shelfmark.core.request_policy import ( + get_source_content_type_capabilities, + parse_policy_mode, + validate_policy_rules, +) from shelfmark.core.settings_registry import ( CheckboxField, CustomComponentField, @@ -14,16 +19,11 @@ from shelfmark.core.settings_registry import ( MultiSelectField, NumberField, SelectField, + SettingsField, TableField, register_on_save, register_settings, ) -from shelfmark.core.request_policy import ( - get_source_content_type_capabilities, - parse_policy_mode, - validate_policy_rules, -) - _REQUEST_DEFAULT_MODE_OPTIONS = [ { @@ -72,7 +72,11 @@ _SELF_SETTINGS_SECTION_OPTIONS = [ _SELF_SETTINGS_SECTION_VALUES = {option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS} _SELF_SETTINGS_SECTION_DEFAULTS = [option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS] _SEARCH_MODE_VALUES = {"direct", "universal"} -_SEARCH_PREFERENCE_PROVIDER_KEYS = {"METADATA_PROVIDER", "METADATA_PROVIDER_AUDIOBOOK", "METADATA_PROVIDER_COMBINED"} +_SEARCH_PREFERENCE_PROVIDER_KEYS = { + "METADATA_PROVIDER", + "METADATA_PROVIDER_AUDIOBOOK", + "METADATA_PROVIDER_COMBINED", +} _SEARCH_PREFERENCE_VALIDATABLE_KEYS = { "SEARCH_MODE", "DEFAULT_RELEASE_SOURCE", @@ -104,19 +108,17 @@ _USERS_HEADING_DESCRIPTION_BY_AUTH_MODE = { } -def _get_request_source_options(): +def _get_request_source_options() -> list[dict[str, str]]: """Build request-policy source options from registered release sources.""" from shelfmark.release_sources import list_available_sources - options = [] - for source in list_available_sources(): - options.append( - { - "value": source["name"], - "label": source["display_name"], - } - ) - return options + return [ + { + "value": source["name"], + "label": source["display_name"], + } + for source in list_available_sources() + ] def _get_valid_release_source_names_for_content_type(content_type: str) -> set[str]: @@ -131,20 +133,20 @@ def _get_valid_release_source_names_for_content_type(content_type: str) -> set[s return valid_sources -def _get_request_policy_rule_columns(): +def _get_request_policy_rule_columns() -> list[dict[str, object]]: source_capabilities = get_source_content_type_capabilities() content_type_options = [] for source_name, supported_types in source_capabilities.items(): normalized_types = [t for t in ("ebook", "audiobook") if t in supported_types] - for content_type in normalized_types: - content_type_options.append( - { - "value": content_type, - "label": "Ebook" if content_type == "ebook" else "Audiobook", - "childOf": source_name, - } - ) + content_type_options.extend( + { + "value": content_type, + "label": "Ebook" if content_type == "ebook" else "Audiobook", + "childOf": source_name, + } + for content_type in normalized_types + ) return [ { @@ -224,7 +226,7 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N return value, None -def _on_save_users(values): +def _on_save_users(values: dict[str, object]) -> dict[str, object]: """Validate users/request-policy settings before persistence.""" if "VISIBLE_SELF_SETTINGS_SECTIONS" in values: raw_sections = values["VISIBLE_SELF_SETTINGS_SECTIONS"] @@ -233,7 +235,9 @@ def _on_save_users(values): elif isinstance(raw_sections, str): candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()] elif isinstance(raw_sections, (list, tuple, set)): - candidate_sections = [str(section).strip() for section in raw_sections if str(section).strip()] + candidate_sections = [ + str(section).strip() for section in raw_sections if str(section).strip() + ] else: return { "error": True, @@ -258,21 +262,25 @@ def _on_save_users(values): values["VISIBLE_SELF_SETTINGS_SECTIONS"] = normalized_sections - if "REQUEST_POLICY_DEFAULT_EBOOK" in values: - if parse_policy_mode(values["REQUEST_POLICY_DEFAULT_EBOOK"]) is None: - return { - "error": True, - "message": "REQUEST_POLICY_DEFAULT_EBOOK must be a valid policy mode", - "values": values, - } + if ( + "REQUEST_POLICY_DEFAULT_EBOOK" in values + and parse_policy_mode(values["REQUEST_POLICY_DEFAULT_EBOOK"]) is None + ): + return { + "error": True, + "message": "REQUEST_POLICY_DEFAULT_EBOOK must be a valid policy mode", + "values": values, + } - if "REQUEST_POLICY_DEFAULT_AUDIOBOOK" in values: - if parse_policy_mode(values["REQUEST_POLICY_DEFAULT_AUDIOBOOK"]) is None: - return { - "error": True, - "message": "REQUEST_POLICY_DEFAULT_AUDIOBOOK must be a valid policy mode", - "values": values, - } + if ( + "REQUEST_POLICY_DEFAULT_AUDIOBOOK" in values + and parse_policy_mode(values["REQUEST_POLICY_DEFAULT_AUDIOBOOK"]) is None + ): + return { + "error": True, + "message": "REQUEST_POLICY_DEFAULT_AUDIOBOOK must be a valid policy mode", + "values": values, + } if "REQUEST_POLICY_RULES" in values: normalized_rules, errors = validate_policy_rules(values["REQUEST_POLICY_RULES"]) @@ -303,7 +311,7 @@ register_on_save("users", _on_save_users) @register_settings("users", "Users & Requests", icon="users", order=6) -def users_settings(): +def users_settings() -> list[SettingsField]: """User management tab - rendered as a custom component on the frontend.""" return [ HeadingField( @@ -330,9 +338,7 @@ def users_settings(): HeadingField( key="requests_heading", title="Requests", - description=( - "Choose what users can download directly and what needs approval first." - ), + description=("Choose what users can download directly and what needs approval first."), ), CheckboxField( key="REQUESTS_ENABLED", @@ -356,9 +362,7 @@ def users_settings(): SelectField( key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", - description=( - "Sets the baseline for all ebook sources." - ), + description=("Sets the baseline for all ebook sources."), options=_REQUEST_DEFAULT_MODE_OPTIONS, default="download", user_overridable=True, @@ -366,9 +370,7 @@ def users_settings(): SelectField( key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", label="Default Audiobook Mode", - description=( - "Sets the baseline for all audiobook sources." - ), + description=("Sets the baseline for all audiobook sources."), options=_REQUEST_DEFAULT_MODE_OPTIONS, default="download", user_overridable=True, diff --git a/shelfmark/core/__init__.py b/shelfmark/core/__init__.py index 46eb8a0d..3791e769 100644 --- a/shelfmark/core/__init__.py +++ b/shelfmark/core/__init__.py @@ -1,5 +1,14 @@ """Core module - shared models, queue, and utilities.""" -from shelfmark.core.models import QueueItem, SearchFilters, QueueStatus -from shelfmark.core.queue import BookQueue, book_queue from shelfmark.core.logger import setup_logger +from shelfmark.core.models import QueueItem, QueueStatus, SearchFilters +from shelfmark.core.queue import BookQueue, book_queue + +__all__ = [ + "BookQueue", + "QueueItem", + "QueueStatus", + "SearchFilters", + "book_queue", + "setup_logger", +] diff --git a/shelfmark/core/activity_routes.py b/shelfmark/core/activity_routes.py index 87b10e52..617a56c3 100644 --- a/shelfmark/core/activity_routes.py +++ b/shelfmark/core/activity_routes.py @@ -2,9 +2,9 @@ from __future__ import annotations -from typing import Any, Callable, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple -from flask import Flask, jsonify, request, session +from flask import Flask, Response, jsonify, request, session from shelfmark.core.activity_view_state_service import ( ADMIN_VIEWER_SCOPE, @@ -12,22 +12,34 @@ from shelfmark.core.activity_view_state_service import ( ActivityViewStateService, user_viewer_scope, ) -from shelfmark.core.download_history_service import ACTIVE_DOWNLOAD_STATUS, DownloadHistoryService, VALID_TERMINAL_STATUSES +from shelfmark.core.download_history_service import ( + ACTIVE_DOWNLOAD_STATUS, + VALID_TERMINAL_STATUSES, + DownloadHistoryService, +) from shelfmark.core.logger import setup_logger -from shelfmark.core.models import ACTIVE_QUEUE_STATUSES, QueueStatus, TERMINAL_QUEUE_STATUSES -from shelfmark.core.request_validation import RequestStatus +from shelfmark.core.models import ( + ACTIVE_QUEUE_STATUSES, + TERMINAL_QUEUE_STATUSES, + QueueStatus, +) from shelfmark.core.request_helpers import ( emit_ws_event, extract_release_source_id, normalize_positive_int, populate_request_usernames, ) -from shelfmark.core.user_db import UserDB +from shelfmark.core.request_validation import RequestStatus + +if TYPE_CHECKING: + from collections.abc import Callable + + from shelfmark.core.user_db import UserDB logger = setup_logger(__name__) -def _normalize_log_field(value: Any) -> str: +def _normalize_log_field(value: object) -> str: if value is None: return "-" text = str(value).strip() @@ -39,15 +51,15 @@ def _log_activity_rejection( *, status_code: int, reason: str, - auth_mode: Any = None, - viewer_scope: Any = None, - item_type: Any = None, - item_key: Any = None, + auth_mode: object = None, + viewer_scope: object = None, + item_type: object = None, + item_key: object = None, item_count: int | None = None, missing_item_keys: list[str] | None = None, - owner_user_id: Any = None, - final_status: Any = None, - request_id: Any = None, + owner_user_id: object = None, + final_status: object = None, + request_id: object = None, ) -> None: parts = [ f"Activity {action} rejected", @@ -86,16 +98,16 @@ def _activity_error_response( status_code: int, error: str, code: str | None = None, - auth_mode: Any = None, - viewer_scope: Any = None, - item_type: Any = None, - item_key: Any = None, + auth_mode: object = None, + viewer_scope: object = None, + item_type: object = None, + item_key: object = None, item_count: int | None = None, missing_item_keys: list[str] | None = None, - owner_user_id: Any = None, - final_status: Any = None, - request_id: Any = None, -): + owner_user_id: object = None, + final_status: object = None, + request_id: object = None, +) -> tuple[Response, int]: _log_activity_rejection( action, status_code=status_code, @@ -119,7 +131,9 @@ def _activity_error_response( return jsonify(payload), status_code -def _require_authenticated(resolve_auth_mode: Callable[[], str], *, action: str): +def _require_authenticated( + resolve_auth_mode: Callable[[], str], *, action: str +) -> tuple[Response, int] | None: auth_mode = resolve_auth_mode() if auth_mode == "none": return None @@ -134,12 +148,12 @@ def _require_authenticated(resolve_auth_mode: Callable[[], str], *, action: str) def _resolve_db_user_id( - require_in_auth_mode: bool = True, *, + require_in_auth_mode: bool = True, user_db: UserDB | None = None, action: str | None = None, auth_mode: str | None = None, -): +) -> tuple[int | None, tuple[Response, int] | None]: raw_db_user_id = session.get("db_user_id") if raw_db_user_id is None: if not require_in_auth_mode: @@ -153,7 +167,7 @@ def _resolve_db_user_id( ) try: parsed_db_user_id = int(raw_db_user_id) - except (TypeError, ValueError): + except TypeError, ValueError: if not require_in_auth_mode: return None, None return None, _activity_error_response( @@ -208,7 +222,7 @@ def _resolve_activity_actor( user_db: UserDB, resolve_auth_mode: Callable[[], str], action: str, -) -> tuple[_ActorContext | None, Any | None]: +) -> tuple[_ActorContext | None, object | None]: """Resolve acting user identity for activity mutations. Returns (actor, error_response). On success actor is non-None. @@ -251,7 +265,7 @@ def _activity_ws_room(actor: _ActorContext) -> str: return "admins" -def _check_item_ownership(actor: _ActorContext, row: dict[str, Any]) -> Any | None: +def _check_item_ownership(actor: _ActorContext, row: dict[str, Any]) -> object | None: """Return an error string if the actor doesn't own the item, else None.""" if actor.is_admin: return None @@ -261,14 +275,14 @@ def _check_item_ownership(actor: _ActorContext, row: dict[str, Any]) -> Any | No return None -def _check_terminal_download(row: dict[str, Any]) -> Any | None: +def _check_terminal_download(row: dict[str, Any]) -> object | None: final_status = str(row.get("final_status") or "").strip().lower() if final_status not in VALID_TERMINAL_STATUSES: return "Only terminal downloads can be dismissed" return None -def _check_terminal_request(row: dict[str, Any]) -> Any | None: +def _check_terminal_request(row: dict[str, Any]) -> object | None: if _request_terminal_status(row) is None: return "Only terminal requests can be dismissed" return None @@ -289,7 +303,9 @@ def _request_row_log_context(row: dict[str, Any]) -> dict[str, Any]: } -def _list_visible_requests(user_db: UserDB, *, is_admin: bool, db_user_id: int | None) -> list[dict[str, Any]]: +def _list_visible_requests( + user_db: UserDB, *, is_admin: bool, db_user_id: int | None +) -> list[dict[str, Any]]: if is_admin: request_rows = user_db.list_requests() populate_request_usernames(request_rows, user_db) @@ -300,7 +316,7 @@ def _list_visible_requests(user_db: UserDB, *, is_admin: bool, db_user_id: int | return user_db.list_requests(user_id=db_user_id) -def _parse_item_key(item_key: Any, prefix: str) -> str | None: +def _parse_item_key(item_key: object, prefix: str) -> str | None: """Extract the value after 'prefix:' from an item_key string.""" if not isinstance(item_key, str) or not item_key.startswith(f"{prefix}:"): return None @@ -311,7 +327,9 @@ def _parse_item_key(item_key: Any, prefix: str) -> str | None: _ALL_BUCKET_KEYS = (*ACTIVE_QUEUE_STATUSES, *TERMINAL_QUEUE_STATUSES) -def _build_queue_index(queue_status: dict[str, dict[str, Any]]) -> dict[str, tuple[str, dict[str, Any]]]: +def _build_queue_index( + queue_status: dict[str, dict[str, Any]], +) -> dict[str, tuple[str, dict[str, Any]]]: """Index live queue entries by task id for fast activity lookups.""" queue_index: dict[str, tuple[str, dict[str, Any]]] = {} for bucket_key in _ALL_BUCKET_KEYS: @@ -319,7 +337,9 @@ def _build_queue_index(queue_status: dict[str, dict[str, Any]]) -> dict[str, tup if not isinstance(bucket, dict): continue for task_id, payload in bucket.items(): - normalized_bucket_key = bucket_key.value if isinstance(bucket_key, QueueStatus) else str(bucket_key) + normalized_bucket_key = ( + bucket_key.value if isinstance(bucket_key, QueueStatus) else str(bucket_key) + ) queue_index[str(task_id)] = (normalized_bucket_key, payload) return queue_index @@ -474,12 +494,12 @@ def register_activity_routes( queue_status: Callable[..., dict[str, dict[str, Any]]], sync_request_delivery_states: Callable[..., list[dict[str, Any]]], emit_request_updates: Callable[[list[dict[str, Any]]], None], - ws_manager: Any | None = None, + ws_manager: object | None = None, ) -> None: """Register activity routes.""" @app.route("/api/activity/snapshot", methods=["GET"]) - def api_activity_snapshot(): + def api_activity_snapshot() -> Response | tuple[Response, int]: auth_gate = _require_authenticated(resolve_auth_mode, action="snapshot") if auth_gate is not None: return auth_gate @@ -548,7 +568,7 @@ def register_activity_routes( ) @app.route("/api/activity/dismiss", methods=["POST"]) - def api_activity_dismiss(): + def api_activity_dismiss() -> Response | tuple[Response, int]: auth_gate = _require_authenticated(resolve_auth_mode, action="dismiss") if auth_gate is not None: return auth_gate @@ -630,7 +650,10 @@ def register_activity_routes( item_type="download", item_key=f"download:{task_id}", ) - dismissal_item = {"item_type": "download", "item_key": f"download:{task_id}"} + dismissal_item = { + "item_type": "download", + "item_key": f"download:{task_id}", + } elif item_type == "request": request_id = normalize_positive_int(_parse_item_key(item_key, "request")) @@ -688,7 +711,10 @@ def register_activity_routes( item_type="request", item_key=f"request:{request_id}", ) - dismissal_item = {"item_type": "request", "item_key": f"request:{request_id}"} + dismissal_item = { + "item_type": "request", + "item_key": f"request:{request_id}", + } else: return _activity_error_response( "dismiss", @@ -715,7 +741,7 @@ def register_activity_routes( return jsonify({"status": "dismissed", "item": dismissal_item}) @app.route("/api/activity/dismiss-many", methods=["POST"]) - def api_activity_dismiss_many(): + def api_activity_dismiss_many() -> Response | tuple[Response, int]: auth_gate = _require_authenticated(resolve_auth_mode, action="dismiss_many") if auth_gate is not None: return auth_gate @@ -860,7 +886,9 @@ def register_activity_routes( item_count=len(items), **_request_row_log_context(request_row), ) - dismissal_items.append({"item_type": "request", "item_key": f"request:{request_id}"}) + dismissal_items.append( + {"item_type": "request", "item_key": f"request:{request_id}"} + ) continue return _activity_error_response( @@ -904,7 +932,7 @@ def register_activity_routes( return jsonify({"status": "dismissed", "count": dismissed_count}) @app.route("/api/activity/history", methods=["GET"]) - def api_activity_history(): + def api_activity_history() -> Response | tuple[Response, int]: auth_gate = _require_authenticated(resolve_auth_mode, action="history") if auth_gate is not None: return auth_gate @@ -924,9 +952,15 @@ def register_activity_routes( if offset is None: offset = 0 if limit < 1: - return _activity_error_response("history", status_code=400, error="limit must be a positive integer") + return _activity_error_response( + "history", status_code=400, error="limit must be a positive integer" + ) if offset < 0: - return _activity_error_response("history", status_code=400, error="offset must be a non-negative integer") + return _activity_error_response( + "history", + status_code=400, + error="offset must be a non-negative integer", + ) history_rows = activity_view_state_service.list_history( viewer_scope=actor.viewer_scope, @@ -942,21 +976,25 @@ def register_activity_routes( dismissed_at = history_row.get("dismissed_at") if not isinstance(dismissed_at, str) or not dismissed_at.strip(): - raise RuntimeError(f"Activity history state missing dismissed_at for {item_key}") + msg = f"Activity history state missing dismissed_at for {item_key}" + raise RuntimeError(msg) if item_type == "download": task_id = _parse_item_key(item_key, "download") if task_id is None: - raise RuntimeError(f"Invalid activity history item_key: {item_key}") + msg = f"Invalid activity history item_key: {item_key}" + raise RuntimeError(msg) download_row = download_history_service.get_by_task_id(task_id) if download_row is None: - raise RuntimeError(f"Download history row not found for {item_key}") + msg = f"Download history row not found for {item_key}" + raise RuntimeError(msg) if not actor.is_admin: owner_user_id = normalize_positive_int(download_row.get("user_id")) if owner_user_id != actor.db_user_id: - raise RuntimeError(f"Viewer state out of scope for {item_key}") + msg = f"Viewer state out of scope for {item_key}" + raise RuntimeError(msg) effective_download_row = _effective_download_row_for_activity( download_row, @@ -973,16 +1011,19 @@ def register_activity_routes( if item_type == "request": request_id = normalize_positive_int(_parse_item_key(item_key, "request")) if request_id is None: - raise RuntimeError(f"Invalid activity history item_key: {item_key}") + msg = f"Invalid activity history item_key: {item_key}" + raise RuntimeError(msg) request_row = user_db.get_request(request_id) if request_row is None: - raise RuntimeError(f"Request row not found for {item_key}") + msg = f"Request row not found for {item_key}" + raise RuntimeError(msg) if not actor.is_admin: owner_user_id = normalize_positive_int(request_row.get("user_id")) if owner_user_id != actor.db_user_id: - raise RuntimeError(f"Viewer state out of scope for {item_key}") + msg = f"Viewer state out of scope for {item_key}" + raise RuntimeError(msg) populate_request_usernames([request_row], user_db) entry = _request_history_entry( @@ -990,16 +1031,18 @@ def register_activity_routes( dismissed_at=dismissed_at, ) if entry is None: - raise RuntimeError(f"Failed to build request history entry for {item_key}") + msg = f"Failed to build request history entry for {item_key}" + raise RuntimeError(msg) payload.append(entry) continue - raise RuntimeError(f"Unknown activity history item_type: {item_type}") + msg = f"Unknown activity history item_type: {item_type}" + raise RuntimeError(msg) return jsonify(payload) @app.route("/api/activity/history", methods=["DELETE"]) - def api_activity_history_clear(): + def api_activity_history_clear() -> Response | tuple[Response, int]: auth_gate = _require_authenticated(resolve_auth_mode, action="history_clear") if auth_gate is not None: return auth_gate diff --git a/shelfmark/core/activity_view_state_service.py b/shelfmark/core/activity_view_state_service.py index 4539e53f..c7401946 100644 --- a/shelfmark/core/activity_view_state_service.py +++ b/shelfmark/core/activity_view_state_service.py @@ -8,7 +8,6 @@ from typing import Any from shelfmark.core.request_helpers import now_utc_iso - VALID_ACTIVITY_ITEM_TYPES = frozenset({"download", "request"}) ADMIN_VIEWER_SCOPE = "admin:shared" NOAUTH_VIEWER_SCOPE = "noauth:shared" @@ -17,58 +16,65 @@ USER_VIEWER_SCOPE_PREFIX = "user:" def user_viewer_scope(user_id: int) -> str: if not isinstance(user_id, int) or user_id < 1: - raise ValueError("user_id must be a positive integer") + msg = "user_id must be a positive integer" + raise ValueError(msg) return f"{USER_VIEWER_SCOPE_PREFIX}{user_id}" -def normalize_viewer_scope(viewer_scope: Any) -> str: +def normalize_viewer_scope(viewer_scope: object) -> str: if not isinstance(viewer_scope, str) or not viewer_scope.strip(): - raise ValueError("viewer_scope must be a non-empty string") + msg = "viewer_scope must be a non-empty string" + raise ValueError(msg) normalized = viewer_scope.strip() if normalized in {ADMIN_VIEWER_SCOPE, NOAUTH_VIEWER_SCOPE}: return normalized if not normalized.startswith(USER_VIEWER_SCOPE_PREFIX): - raise ValueError( - "viewer_scope must be one of: admin:shared, noauth:shared, or user:" - ) + msg = "viewer_scope must be one of: admin:shared, noauth:shared, or user:" + raise ValueError(msg) - raw_user_id = normalized[len(USER_VIEWER_SCOPE_PREFIX):].strip() + raw_user_id = normalized[len(USER_VIEWER_SCOPE_PREFIX) :].strip() try: parsed_user_id = int(raw_user_id) except (TypeError, ValueError) as exc: - raise ValueError("viewer_scope user id must be a positive integer") from exc + msg = "viewer_scope user id must be a positive integer" + raise ValueError(msg) from exc return user_viewer_scope(parsed_user_id) -def _normalize_item_type(item_type: Any) -> str: +def _normalize_item_type(item_type: object) -> str: if not isinstance(item_type, str) or not item_type.strip(): - raise ValueError("item_type must be a non-empty string") + msg = "item_type must be a non-empty string" + raise ValueError(msg) normalized = item_type.strip().lower() if normalized not in VALID_ACTIVITY_ITEM_TYPES: - raise ValueError("item_type must be one of: download, request") + msg = "item_type must be one of: download, request" + raise ValueError(msg) return normalized -def _normalize_item_key(item_key: Any, *, item_type: str) -> str: +def _normalize_item_key(item_key: object, *, item_type: str) -> str: if not isinstance(item_key, str) or not item_key.strip(): - raise ValueError("item_key must be a non-empty string") + msg = "item_key must be a non-empty string" + raise ValueError(msg) normalized = item_key.strip() expected_prefix = f"{item_type}:" if not normalized.startswith(expected_prefix): - raise ValueError(f"item_key must be in the format {expected_prefix}") + msg_0 = f"item_key must be in the format {expected_prefix}" + raise ValueError(msg_0) if not normalized.split(":", 1)[1].strip(): - raise ValueError(f"item_key must be in the format {expected_prefix}") + msg_0 = f"item_key must be in the format {expected_prefix}" + raise ValueError(msg_0) return normalized class ActivityViewStateService: """Service for per-viewer activity dismissal and history visibility.""" - def __init__(self, db_path: str): + def __init__(self, db_path: str) -> None: self._db_path = db_path self._lock = threading.Lock() @@ -215,7 +221,12 @@ class ActivityViewStateService: dismissed_at = excluded.dismissed_at, cleared_at = NULL """, - (normalized_scope, normalized_type, normalized_key, dismissed_at), + ( + normalized_scope, + normalized_type, + normalized_key, + dismissed_at, + ), ) rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0 total += max(rowcount, 0) @@ -286,8 +297,7 @@ class ActivityViewStateService: def delete_items(self, *, item_type: str, item_keys: list[str]) -> int: normalized_type = _normalize_item_type(item_type) normalized_keys = [ - _normalize_item_key(item_key, item_type=normalized_type) - for item_key in item_keys + _normalize_item_key(item_key, item_type=normalized_type) for item_key in item_keys ] if not normalized_keys: return 0 diff --git a/shelfmark/core/admin_routes.py b/shelfmark/core/admin_routes.py index 0d6ceb40..70b201a5 100644 --- a/shelfmark/core/admin_routes.py +++ b/shelfmark/core/admin_routes.py @@ -4,12 +4,12 @@ Registers /api/admin/users CRUD endpoints for managing users. All endpoints require admin session. """ -from functools import wraps import os import sqlite3 -from typing import Any +from functools import wraps +from typing import TYPE_CHECKING, Any -from flask import Flask, g, jsonify, request, session +from flask import Flask, Response, g, jsonify, request, session from werkzeug.security import generate_password_hash from shelfmark.config.booklore_settings import ( @@ -17,7 +17,6 @@ from shelfmark.config.booklore_settings import ( get_booklore_path_options, ) from shelfmark.config.env import CWA_DB_PATH -from shelfmark.core.config import config as app_config from shelfmark.core.admin_settings_routes import ( register_admin_settings_routes, validate_user_settings, @@ -31,12 +30,24 @@ from shelfmark.core.auth_modes import ( load_active_auth_mode, normalize_auth_source, ) +from shelfmark.core.config import config as app_config from shelfmark.core.cwa_user_sync import sync_cwa_users_from_rows from shelfmark.core.logger import setup_logger -from shelfmark.core.user_db import UserDB + +if TYPE_CHECKING: + from collections.abc import Callable + + from shelfmark.core.user_db import UserDB logger = setup_logger(__name__) +__all__ = [ + "get_booklore_library_options", + "get_booklore_path_options", + "register_admin_routes", + "validate_user_settings", +] + def _get_user_edit_capabilities( user: dict[str, Any], @@ -85,8 +96,7 @@ def _oidc_role_management_message(security_config: dict[str, Any] | None = None) f"'{admin_group}' group in your identity provider" ) return ( - "Disable 'Use Admin Group for Authorization' in security settings " - "to manage roles manually" + "Disable 'Use Admin Group for Authorization' in security settings to manage roles manually" ) @@ -109,8 +119,6 @@ def _serialize_user( return payload - - def _sync_all_cwa_users(user_db: UserDB) -> dict[str, int]: """Sync all users from the Calibre-Web database into users.db.""" if not CWA_DB_PATH or not CWA_DB_PATH.exists(): @@ -132,15 +140,18 @@ def _sync_all_cwa_users(user_db: UserDB) -> dict[str, int]: def register_admin_routes(app: Flask, user_db: UserDB) -> None: """Register admin user management routes on the Flask app.""" - def _require_admin(f): + def _require_admin( + f: Callable[..., Response | tuple[Response, int]], + ) -> Callable[..., Response | tuple[Response, int]]: """Decorator to require admin session for admin routes. In no-auth mode, everyone has access (is_admin defaults True). In auth-required modes, requires an authenticated session with admin role. Caches the resolved auth_mode in ``g.auth_mode`` for the request. """ + @wraps(f) - def decorated(*args, **kwargs): + def decorated(*args, **kwargs) -> Response | tuple[Response, int]: auth_mode = load_active_auth_mode(CWA_DB_PATH, user_db=user_db) g.auth_mode = auth_mode if auth_mode != "none": @@ -149,22 +160,20 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: if not session.get("is_admin", False): return jsonify({"error": "Admin access required"}), 403 return f(*args, **kwargs) + return decorated @app.route("/api/admin/users", methods=["GET"]) @_require_admin - def admin_list_users(): + def admin_list_users() -> Response | tuple[Response, int]: """List all users.""" users = user_db.list_users() auth_mode = g.auth_mode - return jsonify([ - _serialize_user(u, auth_mode) - for u in users - ]) + return jsonify([_serialize_user(u, auth_mode) for u in users]) @app.route("/api/admin/users", methods=["POST"]) @_require_admin - def admin_create_user(): + def admin_create_user() -> Response | tuple[Response, int]: """Create a new user with password authentication.""" data = request.get_json() or {} auth_mode = g.auth_mode @@ -176,13 +185,15 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: role = data.get("role", "user") if auth_mode in {AUTH_SOURCE_PROXY, AUTH_SOURCE_CWA}: - return jsonify({ - "error": "Local user creation is disabled in this authentication mode", - "message": ( - "Users are provisioned by your external authentication source. " - "Switch to builtin or OIDC mode to create local users." - ), - }), 400 + return jsonify( + { + "error": "Local user creation is disabled in this authentication mode", + "message": ( + "Users are provisioned by your external authentication source. " + "Switch to builtin or OIDC mode to create local users." + ), + } + ), 400 if not username: return jsonify({"error": "Username is required"}), 400 @@ -213,9 +224,11 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: except ValueError: return jsonify({"error": "Username already exists"}), 409 logger.info( - "Shelfmark user created " - f"(source=manual_admin_create, created_by={session.get('user_id', 'unknown')}, " - f"username={username}, role={role}, auth_source={AUTH_SOURCE_BUILTIN})" + "Shelfmark user created (source=manual_admin_create, created_by=%s, username=%s, role=%s, auth_source=%s)", + session.get("user_id", "unknown"), + username, + role, + AUTH_SOURCE_BUILTIN, ) return jsonify( _serialize_user( @@ -226,7 +239,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: @app.route("/api/admin/users/", methods=["GET"]) @_require_admin - def admin_get_user(user_id): + def admin_get_user(user_id: int) -> Response | tuple[Response, int]: """Get a user by ID with their settings.""" user = user_db.get_user(user_id=user_id) if not user: @@ -241,7 +254,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: @app.route("/api/admin/users/", methods=["PUT"]) @_require_admin - def admin_update_user(user_id): + def admin_update_user(user_id: int) -> Response | tuple[Response, int]: """Update user fields and/or settings.""" user = user_db.get_user(user_id=user_id) if not user: @@ -258,10 +271,12 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: password = data.get("password", "") if password: if not capabilities["canSetPassword"]: - return jsonify({ - "error": f"Cannot set password for {auth_source.upper()} users", - "message": "Password authentication is only available for local users.", - }), 400 + return jsonify( + { + "error": f"Cannot set password for {auth_source.upper()} users", + "message": "Password authentication is only available for local users.", + } + ), 400 if len(password) < 4: return jsonify({"error": "Password must be at least 4 characters"}), 400 user_db.update_user(user_id, password_hash=generate_password_hash(password)) @@ -277,40 +292,49 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: role_changed = "role" in user_fields and user_fields["role"] != user.get("role") email_changed = "email" in user_fields and user_fields["email"] != user.get("email") - display_name_changed = ( - "display_name" in user_fields - and user_fields["display_name"] != user.get("display_name") - ) + display_name_changed = "display_name" in user_fields and user_fields[ + "display_name" + ] != user.get("display_name") if role_changed and not capabilities["canEditRole"]: if auth_source == AUTH_SOURCE_OIDC: - return jsonify({ - "error": "Cannot change role for OIDC user when group-based authorization is enabled", - "message": _oidc_role_management_message(), - }), 400 + return jsonify( + { + "error": "Cannot change role for OIDC user when group-based authorization is enabled", + "message": _oidc_role_management_message(), + } + ), 400 - return jsonify({ - "error": f"Cannot change role for {auth_source.upper()} users", - "message": "Role is managed by the external authentication source.", - }), 400 + return jsonify( + { + "error": f"Cannot change role for {auth_source.upper()} users", + "message": "Role is managed by the external authentication source.", + } + ), 400 if email_changed and not capabilities["canEditEmail"]: if auth_source == AUTH_SOURCE_CWA: - return jsonify({ - "error": "Cannot change email for CWA users", - "message": "Email is synced from Calibre-Web.", - }), 400 + return jsonify( + { + "error": "Cannot change email for CWA users", + "message": "Email is synced from Calibre-Web.", + } + ), 400 - return jsonify({ - "error": "Cannot change email for OIDC users", - "message": "Email is managed by your identity provider.", - }), 400 + return jsonify( + { + "error": "Cannot change email for OIDC users", + "message": "Email is managed by your identity provider.", + } + ), 400 if display_name_changed and not capabilities["canEditDisplayName"]: - return jsonify({ - "error": "Cannot change display name for OIDC users", - "message": "Display name is managed by your identity provider.", - }), 400 + return jsonify( + { + "error": "Cannot change display name for OIDC users", + "message": "Display name is managed by your identity provider.", + } + ), 400 # Allow demoting the last admin account. # Auth mode resolution automatically falls back to "none" when no @@ -331,15 +355,18 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: validated_settings, validation_errors = validate_user_settings(data["settings"]) if validation_errors: - return jsonify({ - "error": "Invalid settings payload", - "details": validation_errors, - }), 400 + return jsonify( + { + "error": "Invalid settings payload", + "details": validation_errors, + } + ), 400 user_db.set_user_settings(user_id, validated_settings) # Ensure runtime reads see updated per-user overrides immediately. try: from shelfmark.core.config import config as app_config + app_config.refresh(force=True) except Exception: pass @@ -350,30 +377,36 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: g.auth_mode, ) result["settings"] = user_db.get_user_settings(user_id) - logger.info(f"Admin updated user {user_id}") + logger.info("Admin updated user %s", user_id) return jsonify(result) @app.route("/api/admin/users/sync-cwa", methods=["POST"]) @_require_admin - def admin_sync_cwa_users(): + def admin_sync_cwa_users() -> Response | tuple[Response, int]: """Manually sync users from Calibre-Web into users.db.""" if g.auth_mode != AUTH_SOURCE_CWA: - return jsonify({ - "error": "CWA sync is only available when CWA authentication is enabled", - }), 400 + return jsonify( + { + "error": "CWA sync is only available when CWA authentication is enabled", + } + ), 400 try: summary = _sync_all_cwa_users(user_db) except FileNotFoundError: - return jsonify({ - "error": "Calibre-Web database is not available", - "message": "Verify app.db is mounted and readable at /auth/app.db.", - }), 503 - except Exception as exc: - logger.error(f"Failed to sync CWA users: {exc}") - return jsonify({ - "error": "Failed to sync users from Calibre-Web", - }), 500 + return jsonify( + { + "error": "Calibre-Web database is not available", + "message": "Verify app.db is mounted and readable at /auth/app.db.", + } + ), 503 + except Exception: + logger.exception("Failed to sync CWA users") + return jsonify( + { + "error": "Failed to sync users from Calibre-Web", + } + ), 500 message = ( f"Synced {summary['total']} CWA users " @@ -381,17 +414,19 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: f"{summary.get('deleted', 0)} deleted)." ) logger.info(message) - return jsonify({ - "success": True, - "message": message, - **summary, - }) + return jsonify( + { + "success": True, + "message": message, + **summary, + } + ) register_admin_settings_routes(app, user_db, _require_admin) @app.route("/api/admin/users/", methods=["DELETE"]) @_require_admin - def admin_delete_user(user_id): + def admin_delete_user(user_id: int) -> Response | tuple[Response, int]: """Delete a user.""" # Prevent self-deletion if session.get("db_user_id") == user_id: @@ -406,15 +441,17 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: user.get("oidc_subject"), ) if auth_source == AUTH_SOURCE_CWA and auth_source == g.auth_mode: - return jsonify({ - "error": f"Cannot delete active {auth_source.upper()} users", - "message": f"{auth_source.upper()} users are automatically re-provisioned on login.", - }), 400 + return jsonify( + { + "error": f"Cannot delete active {auth_source.upper()} users", + "message": f"{auth_source.upper()} users are automatically re-provisioned on login.", + } + ), 400 # Allow deleting the last local admin account. # Auth mode resolution automatically falls back to "none" when no # local password admin remains. user_db.delete_user(user_id) - logger.info(f"Admin deleted user {user_id}: {user['username']}") + logger.info("Admin deleted user %s: %s", user_id, user["username"]) return jsonify({"success": True}) diff --git a/shelfmark/core/admin_settings_routes.py b/shelfmark/core/admin_settings_routes.py index 2676544a..bdb35b76 100644 --- a/shelfmark/core/admin_settings_routes.py +++ b/shelfmark/core/admin_settings_routes.py @@ -1,27 +1,37 @@ """Admin settings-introspection routes and settings validation helpers.""" -from typing import Any, Callable +from typing import TYPE_CHECKING, Any -from flask import Flask, jsonify, request +from flask import Flask, Response, jsonify, request -from shelfmark.core.config import config as app_config from shelfmark.config.notifications_settings import ( build_notification_test_result, is_valid_notification_url, normalize_notification_routes, ) from shelfmark.config.users_settings import validate_search_preference_value +from shelfmark.core.config import config as app_config +from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules from shelfmark.core.settings_registry import load_config_file from shelfmark.core.user_settings_overrides import ( build_user_preferences_payload as _build_user_preferences_payload, +) +from shelfmark.core.user_settings_overrides import ( get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields, +) +from shelfmark.core.user_settings_overrides import ( get_settings_registry as _get_settings_registry, ) -from shelfmark.core.user_db import UserDB -from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules + +if TYPE_CHECKING: + from collections.abc import Callable + + from shelfmark.core.user_db import UserDB -def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: +def validate_user_settings( + settings: dict[str, Any], +) -> tuple[dict[str, Any], list[str]]: settings_registry = _get_settings_registry() field_map = settings_registry.get_settings_field_map() overridable_map = settings_registry.get_user_overridable_fields() @@ -39,10 +49,12 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li valid[key] = None continue - if key in {"REQUEST_POLICY_DEFAULT_EBOOK", "REQUEST_POLICY_DEFAULT_AUDIOBOOK"}: - if parse_policy_mode(value) is None: - errors.append(f"Invalid policy mode for {key}: {value}") - continue + if ( + key in {"REQUEST_POLICY_DEFAULT_EBOOK", "REQUEST_POLICY_DEFAULT_AUDIOBOOK"} + and parse_policy_mode(value) is None + ): + errors.append(f"Invalid policy mode for {key}: {value}") + continue if key == "REQUEST_POLICY_RULES": normalized_rules, rule_errors = validate_policy_rules(value) @@ -61,16 +73,16 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li ) if invalid_count: errors.append( - ( - f"Invalid value for {key}: found {invalid_count} invalid URL(s). " - "Use URL values with a valid scheme, e.g. discord://... or ntfys://..." - ) + f"Invalid value for {key}: found {invalid_count} invalid URL(s). " + "Use URL values with a valid scheme, e.g. discord://... or ntfys://..." ) continue valid[key] = normalized_routes continue - normalized_search_value, search_validation_error = validate_search_preference_value(key, value) + normalized_search_value, search_validation_error = validate_search_preference_value( + key, value + ) if search_validation_error: errors.append(search_validation_error) continue @@ -90,9 +102,7 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li continue candidate_values = [ - str(entry).strip().lower() - for entry in value - if str(entry).strip() + str(entry).strip().lower() for entry in value if str(entry).strip() ] normalized_values: list[str] = [] has_invalid_value = False @@ -120,7 +130,7 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li def build_user_notification_test_response( *, user_id: int, - payload: Any, + payload: object, ) -> tuple[dict[str, Any], int]: from shelfmark.core.config import config as app_config @@ -139,11 +149,11 @@ def build_user_notification_test_response( def register_admin_settings_routes( app: Flask, user_db: UserDB, - require_admin: Callable[[Callable[..., Any]], Callable[..., Any]], + require_admin: Callable[[Callable[..., object]], Callable[..., object]], ) -> None: @app.route("/api/admin/download-defaults", methods=["GET"]) @require_admin - def admin_download_defaults(): + def admin_download_defaults() -> Response | tuple[Response, int]: defaults = { key: ("" if (value := app_config.get(key, field.default)) is None else value) for key, field in _get_ordered_user_overridable_fields("downloads") @@ -156,17 +166,19 @@ def register_admin_settings_routes( @app.route("/api/admin/booklore-options", methods=["GET"]) @require_admin - def admin_booklore_options(): + def admin_booklore_options() -> Response | tuple[Response, int]: from shelfmark.core import admin_routes - return jsonify({ - "libraries": admin_routes.get_booklore_library_options(), - "paths": admin_routes.get_booklore_path_options(), - }) + return jsonify( + { + "libraries": admin_routes.get_booklore_library_options(), + "paths": admin_routes.get_booklore_path_options(), + } + ) @app.route("/api/admin/users//delivery-preferences", methods=["GET"]) @require_admin - def admin_get_delivery_preferences(user_id): + def admin_get_delivery_preferences(user_id: int) -> Response | tuple[Response, int]: user = user_db.get_user(user_id=user_id) if not user: return jsonify({"error": "User not found"}), 404 @@ -180,7 +192,7 @@ def register_admin_settings_routes( @app.route("/api/admin/users//search-preferences", methods=["GET"]) @require_admin - def admin_get_search_preferences(user_id): + def admin_get_search_preferences(user_id: int) -> Response | tuple[Response, int]: user = user_db.get_user(user_id=user_id) if not user: return jsonify({"error": "User not found"}), 404 @@ -194,7 +206,7 @@ def register_admin_settings_routes( @app.route("/api/admin/users//notification-preferences", methods=["GET"]) @require_admin - def admin_get_notification_preferences(user_id): + def admin_get_notification_preferences(user_id: int) -> Response | tuple[Response, int]: user = user_db.get_user(user_id=user_id) if not user: return jsonify({"error": "User not found"}), 404 @@ -208,7 +220,7 @@ def register_admin_settings_routes( @app.route("/api/admin/users//notification-preferences/test", methods=["POST"]) @require_admin - def admin_test_notification_preferences(user_id): + def admin_test_notification_preferences(user_id: int) -> Response | tuple[Response, int]: user = user_db.get_user(user_id=user_id) if not user: return jsonify({"error": "User not found"}), 404 @@ -222,7 +234,7 @@ def register_admin_settings_routes( @app.route("/api/admin/settings/overrides-summary", methods=["GET"]) @require_admin - def admin_settings_overrides_summary(): + def admin_settings_overrides_summary() -> Response | tuple[Response, int]: settings_registry = _get_settings_registry() tab_name = (request.args.get("tab") or "downloads").strip() @@ -241,11 +253,13 @@ def register_admin_settings_routes( if key not in user_settings or user_settings[key] is None: continue entry = keys_payload.setdefault(key, {"count": 0, "users": []}) - entry["users"].append({ - "userId": user_record["id"], - "username": user_record["username"], - "value": user_settings[key], - }) + entry["users"].append( + { + "userId": user_record["id"], + "username": user_record["username"], + "value": user_settings[key], + } + ) for summary in keys_payload.values(): summary["count"] = len(summary["users"]) @@ -254,7 +268,7 @@ def register_admin_settings_routes( @app.route("/api/admin/users//effective-settings", methods=["GET"]) @require_admin - def admin_get_effective_settings(user_id): + def admin_get_effective_settings(user_id: int) -> Response | tuple[Response, int]: user = user_db.get_user(user_id=user_id) if not user: return jsonify({"error": "User not found"}), 404 diff --git a/shelfmark/core/auth_modes.py b/shelfmark/core/auth_modes.py index 94ba9727..f82501f3 100644 --- a/shelfmark/core/auth_modes.py +++ b/shelfmark/core/auth_modes.py @@ -1,7 +1,11 @@ """Authentication mode, auth-source normalization, and admin access policy helpers.""" import os -from typing import Any, Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping AUTH_SOURCE_BUILTIN = "builtin" AUTH_SOURCE_OIDC = "oidc" @@ -17,7 +21,7 @@ AUTH_SOURCE_SET = frozenset(AUTH_SOURCES) _ALWAYS_ADMIN_SETTINGS_TABS = frozenset({"security", "users"}) -def has_local_password_admin(user_db: Any | None = None) -> bool: +def has_local_password_admin(user_db: object | None = None) -> bool: """Return True when at least one local admin with a password exists.""" try: db = user_db @@ -25,7 +29,7 @@ def has_local_password_admin(user_db: Any | None = None) -> bool: from shelfmark.core.user_db import UserDB config_root = os.environ.get("CONFIG_DIR", "/config") - db = UserDB(os.path.join(config_root, "users.db")) + db = UserDB(str(Path(config_root) / "users.db")) db.initialize() return db.has_admin_with_password() @@ -34,8 +38,8 @@ def has_local_password_admin(user_db: Any | None = None) -> bool: def normalize_auth_source( - source: Any, - oidc_subject: Any = None, + source: object, + oidc_subject: object = None, ) -> str: """Resolve a stable auth source value from persisted fields.""" normalized = str(source or "").strip().lower() @@ -48,7 +52,7 @@ def normalize_auth_source( def determine_auth_mode( security_config: Mapping[str, Any], - cwa_db_path: Any | None, + cwa_db_path: object | None, *, has_local_admin: bool = True, ) -> str: @@ -76,9 +80,9 @@ def determine_auth_mode( def load_active_auth_mode( - cwa_db_path: Any | None, + cwa_db_path: object | None, *, - user_db: Any | None = None, + user_db: object | None = None, ) -> str: """Resolve active auth mode using current security config and runtime prerequisites.""" try: @@ -109,7 +113,7 @@ def is_user_active_for_auth_mode(user: Mapping[str, Any], auth_mode: str) -> boo def is_settings_or_onboarding_path(path: str) -> bool: """Return True when request path targets protected admin settings routes.""" - return path.startswith("/api/settings") or path.startswith("/api/onboarding") + return path.startswith(("/api/settings", "/api/onboarding")) def get_settings_tab_from_path(path: str) -> str | None: @@ -117,7 +121,7 @@ def get_settings_tab_from_path(path: str) -> str | None: if not path.startswith("/api/settings/"): return None - suffix = path[len("/api/settings/"):] + suffix = path[len("/api/settings/") :] if not suffix: return None diff --git a/shelfmark/core/cache.py b/shelfmark/core/cache.py index 762a8628..f36c5ddb 100644 --- a/shelfmark/core/cache.py +++ b/shelfmark/core/cache.py @@ -4,32 +4,37 @@ import threading import time from dataclasses import dataclass from functools import wraps -from typing import Any, Callable, Dict, Optional, TypeVar +from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast from shelfmark.core.logger import setup_logger +if TYPE_CHECKING: + from collections.abc import Callable + logger = setup_logger(__name__) -T = TypeVar("T") +P = ParamSpec("P") +R = TypeVar("R") @dataclass class CacheEntry: """A cached value with expiration time.""" - value: Any + + value: object expires_at: float class CacheService: """Thread-safe in-memory cache with TTL support.""" - def __init__(self, max_size: int = 1000): + def __init__(self, max_size: int = 1000) -> None: """Initialize cache with max_size entries before eviction.""" - self._cache: Dict[str, CacheEntry] = {} + self._cache: dict[str, CacheEntry] = {} self._lock = threading.Lock() self._max_size = max_size - def get(self, key: str) -> Optional[Any]: + def get(self, key: str) -> object | None: """Get cached value if not expired.""" with self._lock: entry = self._cache.get(key) @@ -42,17 +47,14 @@ class CacheService: return entry.value - def set(self, key: str, value: Any, ttl: int) -> None: + def set(self, key: str, value: object, ttl: int) -> None: """Cache value with TTL in seconds.""" with self._lock: # Evict oldest entries if at capacity if len(self._cache) >= self._max_size: self._evict_oldest() - self._cache[key] = CacheEntry( - value=value, - expires_at=time.time() + ttl - ) + self._cache[key] = CacheEntry(value=value, expires_at=time.time() + ttl) def invalidate(self, key: str) -> bool: """Remove specific cache entry. Returns True if found.""" @@ -79,10 +81,7 @@ class CacheService: """Remove all expired entries. Returns count removed.""" with self._lock: now = time.time() - expired_keys = [ - key for key, entry in self._cache.items() - if entry.expires_at < now - ] + expired_keys = [key for key, entry in self._cache.items() if entry.expires_at < now] for key in expired_keys: del self._cache[key] return len(expired_keys) @@ -94,21 +93,15 @@ class CacheService: # Remove ~10% of entries, oldest first entries_to_remove = max(1, len(self._cache) // 10) - sorted_entries = sorted( - self._cache.items(), - key=lambda x: x[1].expires_at - ) + sorted_entries = sorted(self._cache.items(), key=lambda x: x[1].expires_at) for key, _ in sorted_entries[:entries_to_remove]: del self._cache[key] - def stats(self) -> Dict[str, int]: + def stats(self) -> dict[str, int]: """Get cache statistics (size, max_size).""" with self._lock: - return { - "size": len(self._cache), - "max_size": self._max_size - } + return {"size": len(self._cache), "max_size": self._max_size} # Global cache instance for metadata providers @@ -128,15 +121,16 @@ def cache_key(*args, **kwargs) -> str: def cacheable( - ttl: Optional[int] = None, - ttl_key: Optional[str] = None, + ttl: int | None = None, + ttl_key: str | None = None, ttl_default: int = 300, - key_prefix: str = "" -): + key_prefix: str = "", +) -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorator for caching function results. Use ttl (static) or ttl_key (from config).""" - def decorator(func: Callable[..., T]) -> Callable[..., T]: + + def decorator(func: Callable[P, R]) -> Callable[P, R]: @wraps(func) - def wrapper(*args, **kwargs) -> T: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: # Check if metadata caching is enabled from shelfmark.core.config import config @@ -156,16 +150,12 @@ def cacheable( # Skip 'self' argument if present (first arg of method) cache_args = args[1:] if args and hasattr(args[0], func.__name__) else args - key = cache_key( - key_prefix or func.__name__, - *cache_args, - **kwargs - ) + key = cache_key(key_prefix or func.__name__, *cache_args, **kwargs) # Check cache cached = _metadata_cache.get(key) if cached is not None: - return cached + return cast("R", cached) # Execute function and cache result result = func(*args, **kwargs) @@ -177,4 +167,5 @@ def cacheable( return result return wrapper + return decorator diff --git a/shelfmark/core/config.py b/shelfmark/core/config.py index d15ef785..caa318f7 100644 --- a/shelfmark/core/config.py +++ b/shelfmark/core/config.py @@ -3,53 +3,65 @@ import os import sqlite3 import time +from importlib import import_module +from pathlib import Path from threading import Lock -from typing import Any, Dict, Optional +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from types import ModuleType + + from shelfmark.core.user_db import UserDB # Import lazily to avoid circular imports _registry_module = None _env_module = None _user_db_module = None +_SETTINGS_REFRESH_COOLDOWN_SECONDS = 0.05 -def _get_registry(): + +def _get_registry() -> ModuleType: """Lazy import of settings registry to avoid circular imports.""" global _registry_module if _registry_module is None: from shelfmark.core import settings_registry + _registry_module = settings_registry return _registry_module -def _get_env(): +def _get_env() -> ModuleType: """Lazy import of env module for fallback values.""" global _env_module if _env_module is None: from shelfmark.config import env + _env_module = env return _env_module -def _get_user_db_module(): +def _get_user_db_module() -> type[UserDB]: """Lazy import of user DB module to avoid optional dependency loops.""" global _user_db_module if _user_db_module is None: from shelfmark.core.user_db import UserDB + _user_db_module = UserDB return _user_db_module class Config: - """ - Dynamic configuration singleton that provides live settings access. + """Dynamic configuration singleton that provides live settings access. Settings are resolved with priority: ENV var > config file > default. Values are cached for performance and can be refreshed when settings change. """ - _instance: Optional['Config'] = None + _instance: Config | None = None _lock = Lock() - def __new__(cls) -> 'Config': + + def __new__(cls) -> Config: if cls._instance is None: with cls._lock: if cls._instance is None: @@ -57,13 +69,13 @@ class Config: cls._instance._initialized = False return cls._instance - def __init__(self): + def __init__(self) -> None: if self._initialized: return - self._cache: Dict[str, Any] = {} - self._field_map: Dict[str, tuple] = {} # key -> (field, tab_name) + self._cache: dict[str, Any] = {} + self._field_map: dict[str, tuple] = {} # key -> (field, tab_name) self._cache_lock = Lock() - self._user_settings_cache: Dict[int, Dict[str, Any]] = {} + self._user_settings_cache: dict[int, dict[str, Any]] = {} self._user_settings_cache_lock = Lock() self._user_db = None self._user_db_load_attempted = False @@ -85,12 +97,12 @@ class Config: # Ensure all settings modules are imported before loading # This handles cases where config is accessed before settings are registered try: - import shelfmark.config.settings # noqa: F401 - main app settings - import shelfmark.config.security # noqa: F401 - security/auth settings - import shelfmark.config.notifications_settings # noqa: F401 - notifications settings - import shelfmark.config.users_settings # noqa: F401 - users/request settings - import shelfmark.release_sources # noqa: F401 - plugin settings - import shelfmark.metadata_providers # noqa: F401 - plugin settings + import_module("shelfmark.config.notifications_settings") + import_module("shelfmark.config.security") + import_module("shelfmark.config.settings") + import_module("shelfmark.config.users_settings") + import_module("shelfmark.metadata_providers") + import_module("shelfmark.release_sources") except ImportError: pass @@ -98,7 +110,7 @@ class Config: # On first load, sync ENV values to config files # This ensures ENV values persist even if ENV vars are later removed - if not hasattr(self, '_env_synced'): + if not hasattr(self, "_env_synced"): registry.sync_env_to_config() self._env_synced = True @@ -112,9 +124,8 @@ class Config: self._loaded = True - def refresh(self, force: bool = False) -> None: - """ - Refresh all cached settings from config files. + def refresh(self, *, force: bool = False) -> None: + """Refresh all cached settings from config files. Call this after settings are updated via the UI to ensure the config singleton reflects the new values. @@ -125,7 +136,7 @@ class Config: (e.g. after a settings write). """ now = time.monotonic() - if not force and (now - self._last_refresh_time) < 0.05: + if not force and (now - self._last_refresh_time) < _SETTINGS_REFRESH_COOLDOWN_SECONDS: return with self._cache_lock: @@ -137,7 +148,7 @@ class Config: self._user_db_load_attempted = False self._last_refresh_time = time.monotonic() - def _get_user_db(self): + def _get_user_db(self) -> UserDB | None: """Get or initialize a UserDB handle if available.""" if self._user_db is not None: return self._user_db @@ -147,16 +158,17 @@ class Config: self._user_db_load_attempted = True try: user_db_cls = _get_user_db_module() - db_path = os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db") + db_path = str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db") user_db = user_db_cls(db_path) user_db.initialize() - self._user_db = user_db - return self._user_db except Exception: # Multi-user support is optional; fall back to global config when unavailable. return None + else: + self._user_db = user_db + return self._user_db - def _get_user_settings(self, user_id: int) -> Dict[str, Any]: + def _get_user_settings(self, user_id: int) -> dict[str, Any]: """Get cached per-user settings from user DB.""" with self._user_settings_cache_lock: if user_id in self._user_settings_cache: @@ -168,7 +180,7 @@ class Config: try: settings = user_db.get_user_settings(user_id) - except (sqlite3.OperationalError, OSError, ValueError, TypeError): + except sqlite3.OperationalError, OSError, ValueError, TypeError: return {} if not isinstance(settings, dict): @@ -178,14 +190,13 @@ class Config: self._user_settings_cache[user_id] = settings return settings - def _get_user_override(self, user_id: int, key: str) -> Any: + def _get_user_override(self, user_id: int, key: str) -> object: """Get a user override for a specific key.""" user_settings = self._get_user_settings(user_id) return user_settings.get(key) - def get(self, key: str, default: Any = None, user_id: Optional[int] = None) -> Any: - """ - Get a setting value by key. + def get(self, key: str, default: object = None, user_id: int | None = None) -> object: + """Get a setting value by key. Args: key: The setting key (e.g., 'MAX_RETRY') @@ -194,6 +205,7 @@ class Config: Returns: The setting value, or default if not found + """ self._ensure_loaded() @@ -213,15 +225,15 @@ class Config: return self._cache.get(key, default) - def __getattr__(self, name: str) -> Any: - """ - Allow attribute-style access to settings. + def __getattr__(self, name: str) -> object: + """Allow attribute-style access to settings. Example: config.MAX_RETRY instead of config.get('MAX_RETRY') """ # Avoid recursion for internal attributes - if name.startswith('_'): - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + if name.startswith("_"): + msg = f"'{type(self).__name__}' object has no attribute '{name}'" + raise AttributeError(msg) self._ensure_loaded() @@ -234,17 +246,18 @@ class Config: if hasattr(env, name): return getattr(env, name) - raise AttributeError(f"Setting '{name}' not found in config or env") + msg = f"Setting '{name}' not found in config or env" + raise AttributeError(msg) def is_from_env(self, key: str) -> bool: - """ - Check if a setting's value comes from an environment variable. + """Check if a setting's value comes from an environment variable. Args: key: The setting key Returns: True if the value is set via ENV var, False otherwise + """ self._ensure_loaded() @@ -255,12 +268,12 @@ class Config: registry = _get_registry() return registry.is_value_from_env(field) - def get_all(self) -> Dict[str, Any]: - """ - Get all cached settings as a dictionary. + def get_all(self) -> dict[str, Any]: + """Get all cached settings as a dictionary. Returns: Dict of all setting keys to their current values + """ self._ensure_loaded() return dict(self._cache) diff --git a/shelfmark/core/cwa_user_sync.py b/shelfmark/core/cwa_user_sync.py index ed71aba3..f633ce5f 100644 --- a/shelfmark/core/cwa_user_sync.py +++ b/shelfmark/core/cwa_user_sync.py @@ -2,16 +2,20 @@ from __future__ import annotations -from typing import Any, Iterable +from typing import TYPE_CHECKING, Any from shelfmark.core.auth_modes import AUTH_SOURCE_CWA, normalize_auth_source from shelfmark.core.external_user_linking import upsert_external_user -from shelfmark.core.user_db import UserDB + +if TYPE_CHECKING: + from collections.abc import Iterable + + from shelfmark.core.user_db import UserDB _CWA_ALIAS_SUFFIX = "__cwa" -def _normalize_email(value: Any) -> str | None: +def _normalize_email(value: object) -> str | None: if value is None: return None email = str(value).strip() @@ -40,7 +44,8 @@ def upsert_cwa_user( context=context, ) if user is None: - raise RuntimeError("Unexpected CWA user sync result: no user returned") + msg = "Unexpected CWA user sync result: no user returned" + raise RuntimeError(msg) return user, action @@ -73,10 +78,13 @@ def sync_cwa_users_from_rows( deleted = 0 for existing_user in user_db.list_users(): - if normalize_auth_source( - existing_user.get("auth_source"), - existing_user.get("oidc_subject"), - ) != AUTH_SOURCE_CWA: + if ( + normalize_auth_source( + existing_user.get("auth_source"), + existing_user.get("oidc_subject"), + ) + != AUTH_SOURCE_CWA + ): continue existing_id = int(existing_user.get("id") or 0) diff --git a/shelfmark/core/download_history_service.py b/shelfmark/core/download_history_service.py index d662df63..20d889f8 100644 --- a/shelfmark/core/download_history_service.py +++ b/shelfmark/core/download_history_service.py @@ -3,15 +3,19 @@ from __future__ import annotations import json -import os import sqlite3 import threading -from datetime import datetime, timezone +from datetime import UTC, datetime +from pathlib import Path from typing import Any from shelfmark.core.logger import setup_logger from shelfmark.core.models import TERMINAL_QUEUE_STATUSES -from shelfmark.core.request_helpers import normalize_optional_positive_int, normalize_optional_text, now_utc_iso +from shelfmark.core.request_helpers import ( + normalize_optional_positive_int, + normalize_optional_text, + now_utc_iso, +) logger = setup_logger(__name__) @@ -21,40 +25,45 @@ ACTIVE_DOWNLOAD_STATUS = "active" VALID_ORIGINS = frozenset({"direct", "requested"}) -def _normalize_task_id(task_id: Any) -> str: +def _normalize_task_id(task_id: object) -> str: normalized = normalize_optional_text(task_id) if normalized is None: - raise ValueError("task_id must be a non-empty string") + msg = "task_id must be a non-empty string" + raise ValueError(msg) return normalized -def _normalize_origin(origin: Any) -> str: +def _normalize_origin(origin: object) -> str: normalized = normalize_optional_text(origin) if normalized is None: return "direct" lowered = normalized.lower() if lowered not in VALID_ORIGINS: - raise ValueError("origin must be one of: direct, requested") + msg = "origin must be one of: direct, requested" + raise ValueError(msg) return lowered -def _normalize_final_status(final_status: Any) -> str: +def _normalize_final_status(final_status: object) -> str: normalized = normalize_optional_text(final_status) if normalized is None: - raise ValueError("final_status must be a non-empty string") + msg = "final_status must be a non-empty string" + raise ValueError(msg) lowered = normalized.lower() if lowered not in VALID_TERMINAL_STATUSES: - raise ValueError("final_status must be one of: complete, error, cancelled") + msg = "final_status must be one of: complete, error, cancelled" + raise ValueError(msg) return lowered -def _normalize_limit(value: Any, *, default: int, minimum: int, maximum: int) -> int: +def _normalize_limit(value: object, *, default: int, minimum: int, maximum: int) -> int: if value is None: return default try: parsed = int(value) except (TypeError, ValueError) as exc: - raise ValueError("limit must be an integer") from exc + msg = "limit must be an integer" + raise ValueError(msg) from exc if parsed < minimum: return minimum if parsed > maximum: @@ -65,7 +74,7 @@ def _normalize_limit(value: Any, *, default: int, minimum: int, maximum: int) -> class DownloadHistoryService: """Service for persisted canonical download activity rows.""" - def __init__(self, db_path: str): + def __init__(self, db_path: str) -> None: self._db_path = db_path self._lock = threading.Lock() @@ -80,7 +89,9 @@ class DownloadHistoryService: if row is None: return None normalized = dict(row) - normalized["retry_payload"] = cls._deserialize_retry_payload(normalized.get("retry_payload")) + normalized["retry_payload"] = cls._deserialize_retry_payload( + normalized.get("retry_payload") + ) return normalized @classmethod @@ -92,23 +103,24 @@ class DownloadHistoryService: return f"download:{task_id}" @staticmethod - def _resolve_existing_download_path(value: Any) -> str | None: + def _resolve_existing_download_path(value: object) -> str | None: normalized = normalize_optional_text(value) if normalized is None: return None - return normalized if os.path.exists(normalized) else None + return normalized if Path(normalized).exists() else None @staticmethod - def _serialize_retry_payload(payload: Any) -> str | None: + def _serialize_retry_payload(payload: object) -> str | None: if payload is None: return None try: return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) except (TypeError, ValueError) as exc: - raise ValueError("retry_payload must be JSON-serializable") from exc + msg = "retry_payload must be JSON-serializable" + raise ValueError(msg) from exc @staticmethod - def _deserialize_retry_payload(value: Any) -> dict[str, Any] | None: + def _deserialize_retry_payload(value: object) -> dict[str, Any] | None: if isinstance(value, dict): return dict(value) normalized = normalize_optional_text(value) @@ -126,7 +138,7 @@ class DownloadHistoryService: normalized_staged_path = normalize_optional_text(staged_path) if normalized_staged_path is None: return False - return os.path.exists(normalized_staged_path) + return Path(normalized_staged_path).exists() @staticmethod def _can_retry_without_staged_source(retry_payload: dict[str, Any]) -> bool: @@ -134,16 +146,16 @@ class DownloadHistoryService: @staticmethod def is_retry_available(row: dict[str, Any]) -> bool: - final_status = str( - row.get("retry_final_status") or row.get("final_status") or "" - ).strip().lower() + final_status = ( + str(row.get("retry_final_status") or row.get("final_status") or "").strip().lower() + ) retry_payload = DownloadHistoryService._deserialize_retry_payload(row.get("retry_payload")) if retry_payload is None: return False has_staged_retry_source = DownloadHistoryService._has_staged_retry_source(retry_payload) - can_retry_without_staged_source = ( - DownloadHistoryService._can_retry_without_staged_source(retry_payload) + can_retry_without_staged_source = DownloadHistoryService._can_retry_without_staged_source( + retry_payload ) request_id = normalize_optional_positive_int(row.get("request_id"), "request_id") if request_id is None: @@ -174,7 +186,9 @@ class DownloadHistoryService: "source": row.get("source"), "source_display_name": row.get("source_display_name"), "status_message": row.get("status_message"), - "download_path": DownloadHistoryService._resolve_existing_download_path(row.get("download_path")), + "download_path": DownloadHistoryService._resolve_existing_download_path( + row.get("download_path") + ), "added_time": DownloadHistoryService._iso_to_epoch(row.get("queued_at")), "user_id": row.get("user_id"), "username": row.get("username"), @@ -183,7 +197,7 @@ class DownloadHistoryService: } @staticmethod - def _iso_to_epoch(value: Any) -> float | None: + def _iso_to_epoch(value: object) -> float | None: if not isinstance(value, str) or not value.strip(): return None normalized = value.strip().replace("Z", "+00:00") @@ -192,7 +206,7 @@ class DownloadHistoryService: except ValueError: return None if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) + parsed = parsed.replace(tzinfo=UTC) return parsed.timestamp() @classmethod @@ -249,10 +263,12 @@ class DownloadHistoryService: normalized_request_id = normalize_optional_positive_int(request_id, "request_id") normalized_source = normalize_optional_text(source) if normalized_source is None: - raise ValueError("source must be a non-empty string") + msg = "source must be a non-empty string" + raise ValueError(msg) normalized_title = normalize_optional_text(title) if normalized_title is None: - raise ValueError("title must be a non-empty string") + msg = "title must be a non-empty string" + raise ValueError(msg) normalized_origin = _normalize_origin(origin) normalized_retry_payload = self._serialize_retry_payload(retry_payload) recorded_at = now_utc_iso() diff --git a/shelfmark/core/external_user_linking.py b/shelfmark/core/external_user_linking.py index d0d28962..4323a426 100644 --- a/shelfmark/core/external_user_linking.py +++ b/shelfmark/core/external_user_linking.py @@ -3,11 +3,13 @@ from __future__ import annotations import re -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal from shelfmark.core.auth_modes import normalize_auth_source from shelfmark.core.logger import setup_logger -from shelfmark.core.user_db import UserDB + +if TYPE_CHECKING: + from shelfmark.core.user_db import UserDB UNSET = object() @@ -21,18 +23,18 @@ MatchReason = Literal[ logger = setup_logger(__name__) -def _normalize_username(value: Any) -> str: +def _normalize_username(value: object) -> str: return str(value or "").strip() -def _normalize_email(value: Any) -> str | None: +def _normalize_email(value: object) -> str | None: if value is None: return None email = str(value).strip() return email or None -def _normalize_display_name(value: Any) -> str | None: +def _normalize_display_name(value: object) -> str | None: if value is None: return None name = str(value).strip() @@ -43,11 +45,13 @@ def _email_key(value: str | None) -> str: return (value or "").strip().lower() -def _normalize_role(value: Any) -> str: +def _normalize_role(value: object) -> str: return "admin" if str(value or "").strip().lower() == "admin" else "user" -def _get_by_subject(user_db: UserDB, subject_field: str | None, subject: str | None) -> dict[str, Any] | None: +def _get_by_subject( + user_db: UserDB, subject_field: str | None, subject: str | None +) -> dict[str, Any] | None: if not subject_field or not subject: return None if subject_field == "oidc_subject": @@ -83,10 +87,14 @@ def find_external_user_match( return by_subject, "subject_match" by_username = user_db.get_user(username=normalized_username) - if by_username and normalize_auth_source( - by_username.get("auth_source"), - by_username.get("oidc_subject"), - ) == auth_source: + if ( + by_username + and normalize_auth_source( + by_username.get("auth_source"), + by_username.get("oidc_subject"), + ) + == auth_source + ): return by_username, "existing_source_username_match" if allow_email_link: @@ -133,7 +141,8 @@ def _find_existing_alias_user( ) -> dict[str, Any] | None: pattern = re.compile(rf"^{re.escape(alias_base)}(?:_\d+)?$") candidates = [ - user for user in user_db.list_users() + user + for user in user_db.list_users() if pattern.match(str(user.get("username") or "")) and normalize_auth_source(user.get("auth_source"), user.get("oidc_subject")) == auth_source ] @@ -158,7 +167,11 @@ def _resolve_create_username( return None, existing, "username_collision_takeover" if strategy == "suffix": - return _next_suffix_username(user_db, requested_username), None, "username_collision_suffix" + return ( + _next_suffix_username(user_db, requested_username), + None, + "username_collision_suffix", + ) alias_base = f"{requested_username}{alias_suffix}" alias_existing = _find_existing_alias_user( @@ -197,7 +210,8 @@ def upsert_external_user( """ normalized_username = _normalize_username(username) if not normalized_username: - raise ValueError("External username is required") + msg = "External username is required" + raise ValueError(msg) normalized_email = _normalize_email(email) if email is not UNSET else None normalized_display_name = ( @@ -227,18 +241,22 @@ def upsert_external_user( user_db.update_user(matched["id"], **updates) mapped = user_db.get_user(user_id=matched["id"]) or matched logger.info( - "External user mapped to existing Shelfmark user " - f"(source={auth_source}, context={context or 'unspecified'}, reason={match_reason}, " - f"external_username={normalized_username}, shelfmark_user_id={mapped['id']}, " - f"shelfmark_username={mapped['username']})" + "External user mapped to existing Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)", + auth_source, + context or "unspecified", + match_reason, + normalized_username, + mapped["id"], + mapped["username"], ) return mapped, "updated" if not allow_create: logger.info( - "External user could not be mapped and creation is disabled " - f"(source={auth_source}, context={context or 'unspecified'}, " - f"external_username={normalized_username})" + "External user could not be mapped and creation is disabled (source=%s, context=%s, external_username=%s)", + auth_source, + context or "unspecified", + normalized_username, ) return None, "not_found" @@ -254,10 +272,13 @@ def upsert_external_user( user_db.update_user(takeover_target["id"], **updates) mapped = user_db.get_user(user_id=takeover_target["id"]) or takeover_target logger.info( - "External user mapped to existing Shelfmark user " - f"(source={auth_source}, context={context or 'unspecified'}, reason={create_reason}, " - f"external_username={normalized_username}, shelfmark_user_id={mapped['id']}, " - f"shelfmark_username={mapped['username']})" + "External user mapped to existing Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)", + auth_source, + context or "unspecified", + create_reason, + normalized_username, + mapped["id"], + mapped["username"], ) return mapped, "updated" @@ -275,9 +296,12 @@ def upsert_external_user( created = user_db.create_user(**create_kwargs) logger.info( - "External user created Shelfmark user " - f"(source={auth_source}, context={context or 'unspecified'}, reason={create_reason}, " - f"external_username={normalized_username}, shelfmark_user_id={created['id']}, " - f"shelfmark_username={created['username']})" + "External user created Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)", + auth_source, + context or "unspecified", + create_reason, + normalized_username, + created["id"], + created["username"], ) return created, "created" diff --git a/shelfmark/core/image_cache.py b/shelfmark/core/image_cache.py index 40c8090d..7dc7c069 100644 --- a/shelfmark/core/image_cache.py +++ b/shelfmark/core/image_cache.py @@ -2,13 +2,12 @@ import ipaddress import json -import os import socket import threading import time +from http import HTTPStatus from io import BytesIO -from pathlib import Path -from typing import Any, Dict, Optional, Tuple +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse import requests @@ -16,22 +15,25 @@ import requests from shelfmark.core.logger import setup_logger from shelfmark.download.network import get_ssl_verify +if TYPE_CHECKING: + from pathlib import Path + logger = setup_logger(__name__) # Image type detection via magic bytes IMAGE_SIGNATURES = { - b'\xff\xd8\xff': ('image/jpeg', 'jpg'), - b'\x89PNG\r\n\x1a\n': ('image/png', 'png'), - b'GIF87a': ('image/gif', 'gif'), - b'GIF89a': ('image/gif', 'gif'), - b'RIFF': ('image/webp', 'webp'), # WebP starts with RIFF + b"\xff\xd8\xff": ("image/jpeg", "jpg"), + b"\x89PNG\r\n\x1a\n": ("image/png", "png"), + b"GIF87a": ("image/gif", "gif"), + b"GIF89a": ("image/gif", "gif"), + b"RIFF": ("image/webp", "webp"), # WebP starts with RIFF } # HTTP headers for image fetching FETCH_HEADERS = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/129.0.0.0 Safari/537.36', - 'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/129.0.0.0 Safari/537.36", + "Accept": "image/webp,image/apng,image/*,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", } # Maximum image size to fetch (5 MB) @@ -44,8 +46,11 @@ NEGATIVE_CACHE_TTL = 3600 # Short enough to retry soon, long enough to prevent spam during one page view TRANSIENT_CACHE_TTL = 60 +_MIN_WEBP_HEADER_LENGTH = 12 +HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND -def _detect_image_type(data: bytes) -> Optional[Tuple[str, str]]: + +def _detect_image_type(data: bytes) -> tuple[str, str] | None: """Detect image type from magic bytes. Args: @@ -53,14 +58,15 @@ def _detect_image_type(data: bytes) -> Optional[Tuple[str, str]]: Returns: Tuple of (content_type, extension) or None if not recognized + """ for signature, (content_type, ext) in IMAGE_SIGNATURES.items(): if data.startswith(signature): return content_type, ext # Special case for WebP - check for WEBP after RIFF - if data.startswith(b'RIFF') and len(data) > 12 and data[8:12] == b'WEBP': - return 'image/webp', 'webp' + if data.startswith(b"RIFF") and len(data) > _MIN_WEBP_HEADER_LENGTH and data[8:12] == b"WEBP": + return "image/webp", "webp" return None @@ -68,20 +74,21 @@ def _detect_image_type(data: bytes) -> Optional[Tuple[str, str]]: class ImageCacheService: """Persistent image cache with LRU eviction and TTL support.""" - def __init__(self, cache_dir: Path, max_size_mb: int = 500, ttl_seconds: int = 0): + def __init__(self, cache_dir: Path, max_size_mb: int = 500, ttl_seconds: int = 0) -> None: """Initialize the image cache. Args: cache_dir: Directory to store cached images max_size_mb: Maximum cache size in megabytes ttl_seconds: Time-to-live in seconds (0 = forever) + """ self.cache_dir = cache_dir self.max_size_bytes = max_size_mb * 1024 * 1024 self.ttl_seconds = ttl_seconds self.index_path = cache_dir / "cache_index.json" self._lock = threading.RLock() - self._index: Dict[str, Dict[str, Any]] = {} + self._index: dict[str, dict[str, Any]] = {} # Stats tracking self._hits = 0 @@ -101,9 +108,9 @@ class ImageCacheService: return try: - with open(self.index_path, 'r') as f: + with self.index_path.open() as f: self._index = json.load(f) - except (json.JSONDecodeError, IOError): + except OSError, json.JSONDecodeError: self._index = {} def _sync_index_with_files(self) -> None: @@ -113,12 +120,12 @@ class ImageCacheService: - Removes entries for files that no longer exist (non-negative only) - Preserves negative cache entries (they have no files) """ - image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} + image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"} added_count = 0 removed_count = 0 # Build set of files that exist on disk - existing_files: Dict[str, Path] = {} + existing_files: dict[str, Path] = {} for file_path in self.cache_dir.iterdir(): if not file_path.is_file(): continue @@ -131,31 +138,31 @@ class ImageCacheService: if cache_id in self._index: continue - ext = file_path.suffix.lstrip('.') + ext = file_path.suffix.lstrip(".") stat = file_path.stat() # Detect content type try: - with open(file_path, 'rb') as f: + with file_path.open("rb") as f: header = f.read(16) detected = _detect_image_type(header) - content_type = detected[0] if detected else f'image/{ext}' - except IOError: - content_type = f'image/{ext}' + content_type = detected[0] if detected else f"image/{ext}" + except OSError: + content_type = f"image/{ext}" self._index[cache_id] = { - 'ext': ext, - 'content_type': content_type, - 'size': stat.st_size, - 'cached_at': stat.st_mtime, - 'accessed_at': stat.st_mtime, + "ext": ext, + "content_type": content_type, + "size": stat.st_size, + "cached_at": stat.st_mtime, + "accessed_at": stat.st_mtime, } added_count += 1 # Remove index entries for missing files (skip negative cache entries) stale_entries = [] for cache_id, entry in self._index.items(): - if entry.get('negative', False): + if entry.get("negative", False): continue # Negative entries don't have files if cache_id not in existing_files: stale_entries.append(cache_id) @@ -171,39 +178,39 @@ class ImageCacheService: """Save cache index to disk.""" try: # Write to temp file first, then rename for atomicity - temp_path = self.index_path.with_suffix('.tmp') - with open(temp_path, 'w') as f: + temp_path = self.index_path.with_suffix(".tmp") + with temp_path.open("w") as f: json.dump(self._index, f) temp_path.rename(self.index_path) - except IOError: + except OSError: pass def _get_image_path(self, cache_id: str, ext: str) -> Path: """Get the file path for a cached image.""" return self.cache_dir / f"{cache_id}.{ext}" - def _is_expired(self, entry: Dict[str, Any]) -> bool: + def _is_expired(self, entry: dict[str, Any]) -> bool: """Check if a cache entry is expired.""" if self.ttl_seconds == 0: return False - return (time.time() - entry.get('cached_at', 0)) > self.ttl_seconds + return (time.time() - entry.get("cached_at", 0)) > self.ttl_seconds - def _is_negative_expired(self, entry: Dict[str, Any]) -> bool: + def _is_negative_expired(self, entry: dict[str, Any]) -> bool: """Check if a negative cache entry is expired. Transient failures (timeouts) expire after TRANSIENT_CACHE_TTL (60s). Permanent failures (404s) expire after NEGATIVE_CACHE_TTL (1 hour). """ - if not entry.get('negative', False): + if not entry.get("negative", False): return False - cached_at = entry.get('cached_at', 0) - ttl = TRANSIENT_CACHE_TTL if entry.get('transient', False) else NEGATIVE_CACHE_TTL + cached_at = entry.get("cached_at", 0) + ttl = TRANSIENT_CACHE_TTL if entry.get("transient", False) else NEGATIVE_CACHE_TTL return (time.time() - cached_at) > ttl def _calculate_total_size(self) -> int: """Calculate total size of cached images.""" - return sum(entry.get('size', 0) for entry in self._index.values()) + return sum(entry.get("size", 0) for entry in self._index.values()) def _evict_if_needed(self, required_space: int = 0) -> None: """Evict old entries if cache is over size limit. @@ -217,10 +224,7 @@ class ImageCacheService: return # Sort entries by accessed_at (oldest first) - sorted_entries = sorted( - self._index.items(), - key=lambda x: x[1].get('accessed_at', 0) - ) + sorted_entries = sorted(self._index.items(), key=lambda x: x[1].get("accessed_at", 0)) evicted_count = 0 for cache_id, entry in sorted_entries: @@ -228,23 +232,23 @@ class ImageCacheService: break # Delete the image file - ext = entry.get('ext', 'jpg') + ext = entry.get("ext", "jpg") image_path = self._get_image_path(cache_id, ext) try: if image_path.exists(): image_path.unlink() - except IOError: + except OSError: pass # Update tracking - current_size -= entry.get('size', 0) + current_size -= entry.get("size", 0) del self._index[cache_id] evicted_count += 1 if evicted_count > 0: self._save_index() - def get(self, cache_id: str) -> Optional[Tuple[bytes, str]]: + def get(self, cache_id: str) -> tuple[bytes, str] | None: """Get a cached image. Args: @@ -252,6 +256,7 @@ class ImageCacheService: Returns: Tuple of (image_data, content_type) or None if not cached/expired + """ with self._lock: entry = self._index.get(cache_id) @@ -265,7 +270,7 @@ class ImageCacheService: return None # Check for negative cache (failed fetch) - if entry.get('negative', False): + if entry.get("negative", False): if self._is_negative_expired(entry): # Negative cache expired, allow retry del self._index[cache_id] @@ -278,12 +283,12 @@ class ImageCacheService: # Check for expired entry if self._is_expired(entry): # Remove expired entry - ext = entry.get('ext', 'jpg') + ext = entry.get("ext", "jpg") image_path = self._get_image_path(cache_id, ext) try: if image_path.exists(): image_path.unlink() - except IOError: + except OSError: pass del self._index[cache_id] self._save_index() @@ -291,9 +296,10 @@ class ImageCacheService: return None # Try to read the cached image - ext = entry.get('ext', 'jpg') - content_type = entry.get('content_type', 'image/jpeg') + ext = entry.get("ext", "jpg") + content_type = entry.get("content_type", "image/jpeg") image_path = self._get_image_path(cache_id, ext) + result: tuple[bytes, str] | None = None try: if not image_path.exists(): @@ -303,19 +309,20 @@ class ImageCacheService: self._misses += 1 return None - with open(image_path, 'rb') as f: + with image_path.open("rb") as f: data = f.read() # Update accessed time - entry['accessed_at'] = time.time() + entry["accessed_at"] = time.time() self._save_index() + result = data, content_type - self._hits += 1 - return data, content_type - - except IOError: + except OSError: self._misses += 1 return None + else: + self._hits += 1 + return result def put(self, cache_id: str, data: bytes, content_type: str) -> bool: """Store an image in the cache. @@ -327,24 +334,24 @@ class ImageCacheService: Returns: True if stored successfully + """ with self._lock: # Detect image type for extension detected = _detect_image_type(data) if detected: content_type, ext = detected + # Fall back to content-type header + elif "jpeg" in content_type or "jpg" in content_type: + ext = "jpg" + elif "png" in content_type: + ext = "png" + elif "gif" in content_type: + ext = "gif" + elif "webp" in content_type: + ext = "webp" else: - # Fall back to content-type header - if 'jpeg' in content_type or 'jpg' in content_type: - ext = 'jpg' - elif 'png' in content_type: - ext = 'png' - elif 'gif' in content_type: - ext = 'gif' - elif 'webp' in content_type: - ext = 'webp' - else: - ext = 'jpg' # Default + ext = "jpg" # Default image_size = len(data) @@ -354,36 +361,37 @@ class ImageCacheService: # Write image to disk image_path = self._get_image_path(cache_id, ext) try: - with open(image_path, 'wb') as f: + with image_path.open("wb") as f: f.write(data) - except IOError: + except OSError: return False # Update index now = time.time() self._index[cache_id] = { - 'ext': ext, - 'content_type': content_type, - 'size': image_size, - 'cached_at': now, - 'accessed_at': now, - 'negative': False, + "ext": ext, + "content_type": content_type, + "size": image_size, + "cached_at": now, + "accessed_at": now, + "negative": False, } self._save_index() return True - def put_negative(self, cache_id: str, transient: bool = False) -> None: + def put_negative(self, cache_id: str, *, transient: bool = False) -> None: """Store a negative cache entry (failed fetch). Args: cache_id: Cache key transient: If True, uses shorter TTL (for timeouts/connection errors) + """ with self._lock: self._index[cache_id] = { - 'negative': True, - 'transient': transient, - 'cached_at': time.time(), + "negative": True, + "transient": transient, + "cached_at": time.time(), } self._save_index() @@ -395,6 +403,7 @@ class ImageCacheService: Returns: True if entry existed and was deleted + """ with self._lock: entry = self._index.get(cache_id) @@ -402,13 +411,13 @@ class ImageCacheService: return False # Delete file if it exists - if not entry.get('negative', False): - ext = entry.get('ext', 'jpg') + if not entry.get("negative", False): + ext = entry.get("ext", "jpg") image_path = self._get_image_path(cache_id, ext) try: if image_path.exists(): image_path.unlink() - except IOError: + except OSError: pass del self._index[cache_id] @@ -420,19 +429,20 @@ class ImageCacheService: Returns: Number of entries cleared + """ with self._lock: count = len(self._index) # Delete all image files for cache_id, entry in self._index.items(): - if not entry.get('negative', False): - ext = entry.get('ext', 'jpg') + if not entry.get("negative", False): + ext = entry.get("ext", "jpg") image_path = self._get_image_path(cache_id, ext) try: if image_path.exists(): image_path.unlink() - except IOError: + except OSError: pass # Clear index @@ -445,28 +455,29 @@ class ImageCacheService: return count - def stats(self) -> Dict[str, Any]: + def stats(self) -> dict[str, Any]: """Get cache statistics. Returns: Dict with size, count, hit rate, etc. + """ with self._lock: total_size = self._calculate_total_size() entry_count = len(self._index) - negative_count = sum(1 for e in self._index.values() if e.get('negative', False)) + negative_count = sum(1 for e in self._index.values() if e.get("negative", False)) total_requests = self._hits + self._misses hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0 return { - 'entry_count': entry_count, - 'negative_count': negative_count, - 'total_size_bytes': total_size, - 'total_size_mb': round(total_size / (1024 * 1024), 2), - 'max_size_mb': self.max_size_bytes / (1024 * 1024), - 'hits': self._hits, - 'misses': self._misses, - 'hit_rate': round(hit_rate, 1), + "entry_count": entry_count, + "negative_count": negative_count, + "total_size_bytes": total_size, + "total_size_mb": round(total_size / (1024 * 1024), 2), + "max_size_mb": self.max_size_bytes / (1024 * 1024), + "hits": self._hits, + "misses": self._misses, + "hit_rate": round(hit_rate, 1), } @staticmethod @@ -477,7 +488,7 @@ class ImageCacheService: except Exception: return False - if parsed.scheme not in ('http', 'https'): + if parsed.scheme not in ("http", "https"): return False hostname = parsed.hostname @@ -490,12 +501,12 @@ class ImageCacheService: ip = ipaddress.ip_address(sockaddr[0]) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: return False - except (socket.gaierror, ValueError): + except socket.gaierror, ValueError: return False return True - def fetch_and_cache(self, cache_id: str, url: str) -> Optional[Tuple[bytes, str]]: + def fetch_and_cache(self, cache_id: str, url: str) -> tuple[bytes, str] | None: """Fetch an image from URL and cache it. Args: @@ -504,10 +515,12 @@ class ImageCacheService: Returns: Tuple of (image_data, content_type) or None on failure + """ + cached_data: tuple[bytes, str] | None = None try: if not self._is_safe_url(url): - logger.warning(f"Blocked request to disallowed URL: {url}") + logger.warning("Blocked request to disallowed URL: %s", url) return None response = requests.get( @@ -520,8 +533,8 @@ class ImageCacheService: response.raise_for_status() # Validate content type - content_type = response.headers.get('content-type', '') - if not content_type.startswith('image/'): + content_type = response.headers.get("content-type", "") + if not content_type.startswith("image/"): self.put_negative(cache_id) return None @@ -545,9 +558,7 @@ class ImageCacheService: detected = _detect_image_type(image_data) if detected: content_type = detected[0] - return image_data, content_type - - return None + cached_data = image_data, content_type except requests.exceptions.Timeout: self.put_negative(cache_id, transient=True) @@ -556,15 +567,17 @@ class ImageCacheService: self.put_negative(cache_id, transient=True) return None except requests.exceptions.HTTPError as e: - is_404 = e.response is not None and e.response.status_code == 404 + is_404 = e.response is not None and e.response.status_code == HTTP_NOT_FOUND self.put_negative(cache_id, transient=not is_404) return None except Exception: return None + else: + return cached_data # Singleton instance (initialized lazily when config is available) -_instance: Optional[ImageCacheService] = None +_instance: ImageCacheService | None = None _instance_lock = threading.Lock() @@ -578,8 +591,8 @@ def get_image_cache() -> ImageCacheService: if _instance is None: with _instance_lock: if _instance is None: - from shelfmark.core.config import config from shelfmark.config.env import CONFIG_DIR + from shelfmark.core.config import config cache_dir = CONFIG_DIR / "covers" max_size_mb = config.get("COVERS_CACHE_MAX_SIZE_MB", 500) @@ -591,7 +604,12 @@ def get_image_cache() -> ImageCacheService: max_size_mb=max_size_mb, ttl_seconds=ttl_seconds, ) - logger.debug(f"Initialized image cache: {cache_dir} (max {max_size_mb}MB, TTL {ttl_days} days)") + logger.debug( + "Initialized image cache: %s (max %sMB, TTL %s days)", + cache_dir, + max_size_mb, + ttl_days, + ) return _instance diff --git a/shelfmark/core/logger.py b/shelfmark/core/logger.py index e12b828d..592226fc 100644 --- a/shelfmark/core/logger.py +++ b/shelfmark/core/logger.py @@ -2,59 +2,59 @@ import logging import sys -from pathlib import Path from logging.handlers import RotatingFileHandler -from typing import Any +from typing import TYPE_CHECKING -from shelfmark.config.env import LOG_FILE, ENABLE_LOGGING, LOG_LEVEL +from shelfmark.config.env import ENABLE_LOGGING, LOG_FILE, LOG_LEVEL + +if TYPE_CHECKING: + from pathlib import Path class CustomLogger(logging.Logger): """Custom logger class with additional error_trace method.""" - def error_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None: + def error_trace(self, msg: object, *args: object, **kwargs: object) -> None: """Log an error message with full stack trace.""" self.log_resource_usage() - kwargs.pop('exc_info', None) + kwargs.pop("exc_info", None) self.error(msg, *args, exc_info=True, **kwargs) - def warning_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None: - """Log a warning message with full stack trace.""" - self.log_resource_usage() - kwargs.pop('exc_info', None) - self.warning(msg, *args, exc_info=True, **kwargs) - - def info_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None: - """Log an info message (stack trace only if exception active).""" - kwargs.pop('exc_info', None) - # Only include exc_info if there's actually an exception - has_exception = sys.exc_info()[0] is not None - self.info(msg, *args, exc_info=has_exception, **kwargs) - - def debug_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None: + def debug_trace(self, msg: object, *args: object, **kwargs: object) -> None: """Log a debug message (stack trace only if exception active).""" - kwargs.pop('exc_info', None) + kwargs.pop("exc_info", None) # Only include exc_info if there's actually an exception has_exception = sys.exc_info()[0] is not None self.debug(msg, *args, exc_info=has_exception, **kwargs) - def log_resource_usage(self): + def log_resource_usage(self) -> None: # Best-effort only; this should never raise during exception logging. try: import psutil + def _get_process_rss_mb(proc: object) -> float | None: + try: + mem = proc.info.get("memory_info") + if mem: + return mem.rss / (1024 * 1024) + except ( + psutil.NoSuchProcess, + psutil.AccessDenied, + KeyError, + AttributeError, + ): + return None + return None + # Sum RSS of all processes for actual app memory (container-friendly), # but fall back gracefully on platforms that restrict process enumeration. app_memory_mb = 0.0 try: - for proc in psutil.process_iter(['memory_info']): - try: - mem = proc.info.get('memory_info') - if mem: - app_memory_mb += mem.rss / (1024 * 1024) - except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError, AttributeError): - continue - except (PermissionError, psutil.AccessDenied, OSError): + for proc in psutil.process_iter(["memory_info"]): + proc_rss_mb = _get_process_rss_mb(proc) + if proc_rss_mb is not None: + app_memory_mb += proc_rss_mb + except PermissionError, psutil.AccessDenied, OSError: try: app_memory_mb = psutil.Process().memory_info().rss / (1024 * 1024) except Exception: @@ -82,6 +82,7 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger: Returns: CustomLogger: Configured logger instance with error_trace method + """ # Register our custom logger class logging.setLoggerClass(CustomLogger) @@ -92,19 +93,21 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger: logger.setLevel(log_level) formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s' + "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s" ) # Console handler for Docker output console_handler = logging.StreamHandler(sys.stdout) console_handler.setFormatter(formatter) console_handler.setLevel(log_level) - console_handler.addFilter(lambda record: record.levelno < logging.ERROR) # Only allow logs below ERROR to stdout + console_handler.addFilter( + lambda record: record.levelno < logging.ERROR + ) # Only allow logs below ERROR to stdout logger.addHandler(console_handler) # Error handler for stderr error_handler = logging.StreamHandler(sys.stderr) - error_handler.setLevel(logging.ERROR) # Error and above go to stderr + error_handler.setLevel(logging.ERROR) # Error and above go to stderr error_handler.setFormatter(formatter) logger.addHandler(error_handler) @@ -117,7 +120,7 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger: file_handler = RotatingFileHandler( log_file, maxBytes=10485760, # 10MB - backupCount=5 + backupCount=5, ) file_handler.setFormatter(formatter) logger.addHandler(file_handler) diff --git a/shelfmark/core/mirrors.py b/shelfmark/core/mirrors.py index 6ef9410c..385f1ee8 100644 --- a/shelfmark/core/mirrors.py +++ b/shelfmark/core/mirrors.py @@ -1,18 +1,23 @@ """Centralized mirror configuration for all download sources.""" -from typing import List +# Lazy import to avoid circular imports + +from typing import TYPE_CHECKING from shelfmark.core.utils import normalize_http_url -# Lazy import to avoid circular imports +if TYPE_CHECKING: + from types import ModuleType + _config_module = None -def _get_config(): +def _get_config() -> ModuleType: """Lazy import of config module to avoid circular imports.""" global _config_module if _config_module is None: from shelfmark.core.config import config + _config_module = config return _config_module @@ -50,9 +55,8 @@ def _normalize_mirror_url(url: str) -> str: return normalize_http_url(url, default_scheme="https") -def get_aa_mirrors() -> List[str]: - """ - Get Anna's Archive mirrors. +def get_aa_mirrors() -> list[str]: + """Get Anna's Archive mirrors. Returns: Ordered list of AA mirror URLs. @@ -60,9 +64,10 @@ def get_aa_mirrors() -> List[str]: If AA_MIRROR_URLS is configured, it is treated as the full list. Otherwise, defaults are used and AA_ADDITIONAL_URLS (legacy) is appended. - Notes: + Notes: - The list is used to populate the AA mirror dropdown in Settings. - When AA_BASE_URL is set to 'auto', mirrors are tried in the order listed. + """ config = _get_config() @@ -96,12 +101,12 @@ def get_aa_mirrors() -> List[str]: return mirrors -def get_libgen_mirrors() -> List[str]: - """ - Get LibGen mirrors: defaults + any additional from config. +def get_libgen_mirrors() -> list[str]: + """Get LibGen mirrors: defaults + any additional from config. Returns: List of LibGen mirror URLs (defaults first, then custom additions). + """ mirrors = [_normalize_mirror_url(url) for url in DEFAULT_LIBGEN_MIRRORS] mirrors = [url for url in mirrors if url] @@ -117,12 +122,12 @@ def get_libgen_mirrors() -> List[str]: return mirrors -def get_zlib_mirrors() -> List[str]: - """ - Get Z-Library mirrors, with primary first. +def get_zlib_mirrors() -> list[str]: + """Get Z-Library mirrors, with primary first. Returns: List of Z-Library mirror URLs, primary first. + """ config = _get_config() @@ -149,11 +154,11 @@ def get_zlib_mirrors() -> List[str]: def get_zlib_primary_url() -> str: - """ - Get the primary Z-Library mirror URL. + """Get the primary Z-Library mirror URL. Returns: Primary Z-Library mirror URL. + """ config = _get_config() primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])) @@ -161,22 +166,22 @@ def get_zlib_primary_url() -> str: def get_zlib_url_template() -> str: - """ - Get Z-Library URL template using configured primary mirror. + """Get Z-Library URL template using configured primary mirror. Returns: URL template with {md5} placeholder. + """ primary = get_zlib_primary_url() return f"{primary}/md5/{{md5}}" -def get_welib_mirrors() -> List[str]: - """ - Get Welib mirrors, with primary first. +def get_welib_mirrors() -> list[str]: + """Get Welib mirrors, with primary first. Returns: List of Welib mirror URLs, primary first. + """ config = _get_config() @@ -203,11 +208,11 @@ def get_welib_mirrors() -> List[str]: def get_welib_primary_url() -> str: - """ - Get the primary Welib mirror URL. + """Get the primary Welib mirror URL. Returns: Primary Welib mirror URL. + """ config = _get_config() primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])) @@ -215,24 +220,24 @@ def get_welib_primary_url() -> str: def get_welib_url_template() -> str: - """ - Get Welib URL template using configured primary mirror. + """Get Welib URL template using configured primary mirror. Returns: URL template with {md5} placeholder. + """ primary = get_welib_primary_url() return f"{primary}/md5/{{md5}}" def get_zlib_cookie_domains() -> set: - """ - Get set of Z-Library domains that need full cookie handling. + """Get set of Z-Library domains that need full cookie handling. Used by internal_bypasser for CF bypass cookie management. Returns: Set of domain strings (without protocol). + """ domains = set() diff --git a/shelfmark/core/models.py b/shelfmark/core/models.py index fe975491..3cec667c 100644 --- a/shelfmark/core/models.py +++ b/shelfmark/core/models.py @@ -1,18 +1,18 @@ """Data structures and models used across the application.""" -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Dict, List, Optional -from enum import Enum import re import time +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import Any def build_filename( title: str, - author: Optional[str] = None, - year: Optional[str] = None, - fmt: Optional[str] = None, + author: str | None = None, + year: str | None = None, + fmt: str | None = None, ) -> str: parts = [] if author: @@ -23,7 +23,7 @@ def build_filename( parts.append(f" ({year})") filename = "".join(parts) - filename = re.sub(r'[\\/:*?"<>|]', '_', filename.strip())[:245] + filename = re.sub(r'[\\/:*?"<>|]', "_", filename.strip())[:245] if fmt: filename = f"{filename}.{fmt}" @@ -31,8 +31,9 @@ def build_filename( return filename -class QueueStatus(str, Enum): +class QueueStatus(StrEnum): """Enum for possible book queue statuses.""" + QUEUED = "queued" RESOLVING = "resolving" LOCATING = "locating" @@ -42,16 +43,25 @@ class QueueStatus(str, Enum): CANCELLED = "cancelled" -TERMINAL_QUEUE_STATUSES: frozenset[QueueStatus] = frozenset({ - QueueStatus.COMPLETE, QueueStatus.ERROR, QueueStatus.CANCELLED, -}) +TERMINAL_QUEUE_STATUSES: frozenset[QueueStatus] = frozenset( + { + QueueStatus.COMPLETE, + QueueStatus.ERROR, + QueueStatus.CANCELLED, + } +) -ACTIVE_QUEUE_STATUSES: frozenset[QueueStatus] = frozenset({ - QueueStatus.QUEUED, QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING, -}) +ACTIVE_QUEUE_STATUSES: frozenset[QueueStatus] = frozenset( + { + QueueStatus.QUEUED, + QueueStatus.RESOLVING, + QueueStatus.LOCATING, + QueueStatus.DOWNLOADING, + } +) -class SearchMode(str, Enum): +class SearchMode(StrEnum): DIRECT = "direct" UNIVERSAL = "universal" @@ -59,11 +69,12 @@ class SearchMode(str, Enum): @dataclass class QueueItem: """Queue item with priority and metadata.""" + book_id: str priority: int added_time: float - def __lt__(self, other): + def __lt__(self, other: QueueItem) -> bool: """Compare items for priority queue (lower priority number = higher precedence).""" if self.priority != other.priority: return self.priority < other.priority @@ -72,60 +83,67 @@ class QueueItem: @dataclass class DownloadTask: - task_id: str # Unique ID (e.g., AA MD5 hash, Prowlarr GUID) - source: str # Handler name ("direct_download", "prowlarr") - title: str # Display title for queue sidebar + task_id: str # Unique ID (e.g., AA MD5 hash, Prowlarr GUID) + source: str # Handler name ("direct_download", "prowlarr") + title: str # Display title for queue sidebar # Display info for queue sidebar - author: Optional[str] = None - year: Optional[str] = None - format: Optional[str] = None - size: Optional[str] = None - preview: Optional[str] = None - content_type: Optional[str] = None # "book (fiction)", "audiobook", "magazine", etc. - source_url: Optional[str] = None # Original release URL used by source-specific handlers - retry_download_url: Optional[str] = None # Resolved download URL for restart-safe retries - retry_download_protocol: Optional[str] = None # Protocol for retry_download_url (e.g. torrent, usenet) - retry_release_name: Optional[str] = None # Display name to send back to external download clients - retry_expected_hash: Optional[str] = None # Optional torrent hash used to match client downloads - retry_ratio_limit: Optional[float] = None # Optional post-download seeding ratio - retry_seeding_time_limit_minutes: Optional[int] = None # Optional post-download seeding time limit - can_retry_without_staged_source: bool = True # Whether the source can restart without a preserved staged file + author: str | None = None + year: str | None = None + format: str | None = None + size: str | None = None + preview: str | None = None + content_type: str | None = None # "book (fiction)", "audiobook", "magazine", etc. + source_url: str | None = None # Original release URL used by source-specific handlers + retry_download_url: str | None = None # Resolved download URL for restart-safe retries + retry_download_protocol: str | None = ( + None # Protocol for retry_download_url (e.g. torrent, usenet) + ) + retry_release_name: str | None = None # Display name to send back to external download clients + retry_expected_hash: str | None = None # Optional torrent hash used to match client downloads + retry_ratio_limit: float | None = None # Optional post-download seeding ratio + retry_seeding_time_limit_minutes: int | None = None # Optional post-download seeding time limit + can_retry_without_staged_source: bool = ( + True # Whether the source can restart without a preserved staged file + ) # Series info (for library naming templates) - series_name: Optional[str] = None - series_position: Optional[float] = None # Float for novellas (e.g., 1.5) - subtitle: Optional[str] = None # Book subtitle for naming templates + series_name: str | None = None + series_position: float | None = None # Float for novellas (e.g., 1.5) + subtitle: str | None = None # Book subtitle for naming templates # Hardlinking support - original_download_path: Optional[str] = None # Path in download client (for hardlinking) + original_download_path: str | None = None # Path in download client (for hardlinking) # Search mode - determines post-download processing behavior # See SearchMode enum for behavioral differences - search_mode: Optional[SearchMode] = None + search_mode: SearchMode | None = None # Output selection for post-processing. # This is captured at queue time so in-flight tasks are not affected if the user changes settings later. - output_mode: Optional[str] = None # e.g. "folder", "booklore", "email" - output_args: Dict[str, Any] = field(default_factory=dict) # Per-output parameters (e.g. email recipient) + output_mode: str | None = None + + output_args: dict[str, Any] = field( + default_factory=dict + ) # Per-output parameters (e.g. email recipient) # User association (multi-user support) - user_id: Optional[int] = None # DB user ID who queued this download - username: Optional[str] = None # Username for {User} template variable - request_id: Optional[int] = None # Origin request ID when queued from request fulfilment + user_id: int | None = None # DB user ID who queued this download + username: str | None = None # Username for {User} template variable + request_id: int | None = None # Origin request ID when queued from request fulfilment # Runtime state priority: int = 0 added_time: float = field(default_factory=time.time) progress: float = 0.0 status: QueueStatus = QueueStatus.QUEUED - status_message: Optional[str] = None - download_path: Optional[str] = None - last_error_message: Optional[str] = None - last_error_type: Optional[str] = None - staged_path: Optional[str] = None + status_message: str | None = None + download_path: str | None = None + last_error_message: str | None = None + last_error_type: str | None = None + staged_path: str | None = None - def __lt__(self, other): + def __lt__(self, other: DownloadTask) -> bool: """Compare tasks for priority queue (lower priority number = higher precedence).""" if self.priority != other.priority: return self.priority < other.priority @@ -141,10 +159,11 @@ class DownloadTask: @dataclass class SearchFilters: """Filters for book search queries.""" - isbn: Optional[List[str]] = None - author: Optional[List[str]] = None - title: Optional[List[str]] = None - lang: Optional[List[str]] = None - sort: Optional[str] = None - content: Optional[List[str]] = None - format: Optional[List[str]] = None + + isbn: list[str] | None = None + author: list[str] | None = None + title: list[str] | None = None + lang: list[str] | None = None + sort: str | None = None + content: list[str] | None = None + format: list[str] | None = None diff --git a/shelfmark/core/naming.py b/shelfmark/core/naming.py index 56743b15..ccfa1122 100644 --- a/shelfmark/core/naming.py +++ b/shelfmark/core/naming.py @@ -1,48 +1,50 @@ """Template-based naming for library organization.""" -import os import re from pathlib import Path -from typing import Dict, Optional, Union, Mapping +from typing import TYPE_CHECKING from shelfmark.core.logger import setup_logger +if TYPE_CHECKING: + from collections.abc import Mapping + logger = setup_logger(__name__) # Known variable tokens, sorted longest-first to avoid partial matches # e.g., "SeriesPosition" must match before "Series" KNOWN_TOKENS = [ - 'seriesposition', - 'originalname', - 'partnumber', - 'subtitle', - 'author', - 'series', - 'title', - 'year', - 'user', + "seriesposition", + "originalname", + "partnumber", + "subtitle", + "author", + "series", + "title", + "year", + "user", ] # Match any {...} block for template parsing -BRACE_PATTERN = re.compile(r'\{([^}]+)\}') +BRACE_PATTERN = re.compile(r"\{([^}]+)\}") # Characters that are invalid in filenames on various filesystems INVALID_CHARS = re.compile(r'[\\/:*?"<>|]') -def _sanitize(name: Optional[str], max_length: int = 245) -> str: +def _sanitize(name: str | None, max_length: int = 245) -> str: """Sanitize a string for filesystem use.""" if not name: return "" - sanitized = INVALID_CHARS.sub('_', name) - sanitized = re.sub(r'^[\s.]+|[\s.]+$', '', sanitized) # Strip whitespace and dots - sanitized = re.sub(r'_+', '_', sanitized) # Collapse underscores + sanitized = INVALID_CHARS.sub("_", name) + sanitized = re.sub(r"^[\s.]+|[\s.]+$", "", sanitized) # Strip whitespace and dots + sanitized = re.sub(r"_+", "_", sanitized) # Collapse underscores return sanitized[:max_length] -def sanitize_filename(name: Optional[str], max_length: int = 245) -> str: +def sanitize_filename(name: str | None, max_length: int = 245) -> str: """Sanitize a string for use as a filename or path component.""" return _sanitize(name, max_length) @@ -51,7 +53,7 @@ def sanitize_filename(name: Optional[str], max_length: int = 245) -> str: sanitize_path_component = sanitize_filename -def format_series_position(position: Optional[Union[str, int, float]]) -> str: +def format_series_position(position: str | float | None) -> str: if position is None: return "" @@ -63,10 +65,10 @@ def format_series_position(position: Optional[Union[str, int, float]]) -> str: # Pads numbers to 9 digits for natural sorting (e.g., "Part 2" -> "Part 000000002") -PAD_NUMBERS_PATTERN = re.compile(r'\d+') +PAD_NUMBERS_PATTERN = re.compile(r"\d+") -def natural_sort_key(path: Union[str, Path]) -> str: +def natural_sort_key(path: str | Path) -> str: """Generate a sort key with padded numbers for natural sorting.""" filename = Path(path).name.lower() return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), filename) @@ -89,7 +91,7 @@ def assign_part_numbers( def parse_naming_template( template: str, - metadata: Mapping[str, Optional[Union[str, int, float]]], + metadata: Mapping[str, str | int | float | None], *, allow_path_separators: bool = True, ) -> str: @@ -99,7 +101,7 @@ def parse_naming_template( # Normalize metadata keys to lowercase for case-insensitive matching normalized = {k.lower(): v for k, v in metadata.items()} - def find_token(content: str) -> tuple[Optional[str], int]: + def find_token(content: str) -> tuple[str | None, int]: content_lower = content.lower() for token in KNOWN_TOKENS: idx = content_lower.find(token) @@ -109,19 +111,19 @@ def parse_naming_template( def token_value(token: str) -> str: value = normalized.get(token) - if token == 'seriesposition': + if token == "seriesposition": value = format_series_position(value) if value is None: return "" return str(value).strip() - def render_block(content: str) -> Optional[str]: + def render_block(content: str) -> str | None: token, idx = find_token(content) if token is None: return None prefix = content[:idx] - suffix = content[idx + len(token):] + suffix = content[idx + len(token) :] value = token_value(token) if not value: return "" @@ -140,7 +142,7 @@ def parse_naming_template( parts: list[str] = [] cursor = 0 for idx, match in enumerate(matches): - parts.append(template[cursor:match.start()]) + parts.append(template[cursor : match.start()]) content = match.group(1) rendered = render_block(content) @@ -157,11 +159,10 @@ def parse_naming_template( include_literal = bool(token_value(next_token)) if include_literal: parts.append(content) - elif not conditional_literal: + elif not conditional_literal and re.search(r"\s", content): # Preserve blocks that look like literal text, but treat bare unknown # placeholders as missing variables. - if re.search(r"\s", content): - parts.append(match.group(0)) + parts.append(match.group(0)) cursor = match.end() @@ -169,41 +170,39 @@ def parse_naming_template( result = "".join(parts) # Clean up any double slashes that might result from empty tokens - result = re.sub(r'/+', '/', result) + result = re.sub(r"/+", "/", result) # Remove leading/trailing slashes - result = result.strip('/') + result = result.strip("/") # Clean up any orphaned separators (e.g., " - " at start/end, or " - - ") - result = re.sub(r'^[\s\-_.]+', '', result) - result = re.sub(r'[\s\-_.]+$', '', result) - result = re.sub(r'(\s*-\s*){2,}', ' - ', result) + result = re.sub(r"^[\s\-_.]+", "", result) + result = re.sub(r"[\s\-_.]+$", "", result) + result = re.sub(r"(\s*-\s*){2,}", " - ", result) # Clean up empty parentheses/brackets - result = re.sub(r'\(\s*\)', '', result) - result = re.sub(r'\[\s*\]', '', result) + result = re.sub(r"\(\s*\)", "", result) + result = re.sub(r"\[\s*\]", "", result) # Final trim of any trailing separators left after cleanup - result = re.sub(r'[\s\-_.]+$', '', result) - - return result + return re.sub(r"[\s\-_.]+$", "", result) def build_library_path( base_path: str, template: str, - metadata: Mapping[str, Optional[Union[str, int, float]]], - extension: Optional[str] = None, + metadata: Mapping[str, str | int | float | None], + extension: str | None = None, ) -> Path: relative = parse_naming_template(template, metadata, allow_path_separators=True) if not relative: # Fallback to title if template produces empty result - title = metadata.get('Title') or metadata.get('title') or 'Unknown' + title = metadata.get("Title") or metadata.get("title") or "Unknown" relative = sanitize_filename(str(title)) # Remove any path traversal attempts - relative = relative.replace('..', '') + relative = relative.replace("..", "") base = Path(base_path).resolve() full_path = (base / relative).resolve() @@ -211,11 +210,12 @@ def build_library_path( # Verify the path is within the base directory try: full_path.relative_to(base) - except ValueError: - raise ValueError(f"Path traversal detected: template would escape library directory") + except ValueError as exc: + msg = "Path traversal detected: template would escape library directory" + raise ValueError(msg) from exc if extension: - ext = extension.lstrip('.') + ext = extension.lstrip(".") # Don't use with_suffix() - it replaces everything after the first dot # e.g., "2.5 - Title" would become "2.epub" instead of "2.5 - Title.epub" full_path = Path(f"{full_path}.{ext}") @@ -223,27 +223,27 @@ def build_library_path( return full_path -def same_filesystem(path1: Union[str, Path], path2: Union[str, Path]) -> bool: +def same_filesystem(path1: str | Path, path2: str | Path) -> bool: """Check if two paths are on the same filesystem.""" path1 = Path(path1) path2 = Path(path2) - def get_device(p: Path) -> Optional[int]: + def get_device(p: Path) -> int | None: try: while not p.exists(): p = p.parent if p == p.parent: break - return os.stat(p).st_dev + return p.stat().st_dev except (OSError, PermissionError) as e: - logger.debug(f"Cannot stat {p}: {e}") + logger.debug("Cannot stat %s: %s", p, e) return None dev1 = get_device(path1) dev2 = get_device(path2) if dev1 is None or dev2 is None: - logger.warning(f"Cannot determine filesystem for hardlink check, falling back to copy") + logger.warning("Cannot determine filesystem for hardlink check, falling back to copy") return False return dev1 == dev2 diff --git a/shelfmark/core/notifications.py b/shelfmark/core/notifications.py index ecc478c8..33489831 100644 --- a/shelfmark/core/notifications.py +++ b/shelfmark/core/notifications.py @@ -5,20 +5,23 @@ from __future__ import annotations import logging import threading from concurrent.futures import ThreadPoolExecutor -from contextlib import contextmanager +from contextlib import contextmanager, suppress from dataclasses import dataclass -from enum import Enum -from typing import Any, Iterable, Iterator +from enum import StrEnum +from typing import TYPE_CHECKING, Any from urllib.parse import urlsplit try: import apprise -except Exception: # pragma: no cover - exercised in tests via monkeypatch +except ImportError: # pragma: no cover - exercised in tests via monkeypatch apprise = None # type: ignore[assignment] from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + logger = setup_logger(__name__) # Small pool for non-blocking dispatch. Notification sends are I/O bound and infrequent. @@ -32,7 +35,7 @@ _APPRISE_LOGO_URL = ( _APPRISE_LOGGER_NAME = "apprise" -class NotificationEvent(str, Enum): +class NotificationEvent(StrEnum): """Global notification event identifiers.""" REQUEST_CREATED = "request_created" @@ -57,7 +60,7 @@ class NotificationContext: error_message: str | None = None -def _normalize_urls(value: Any) -> list[str]: +def _normalize_urls(value: object) -> list[str]: if value is None: return [] @@ -103,7 +106,7 @@ def _extract_url_schemes(urls: Iterable[str]) -> list[str]: class _AppriseLogCapture(logging.Handler): - def __init__(self, *, thread_id: int): + def __init__(self, *, thread_id: int) -> None: super().__init__(level=logging.INFO) self.records: list[tuple[int, str, str, str]] = [] self._thread_id = thread_id @@ -126,7 +129,9 @@ class _AppriseLogCapture(logging.Handler): @contextmanager -def _capture_apprise_logs(*, min_level: int = logging.INFO) -> Iterator[list[tuple[int, str, str, str]]]: +def _capture_apprise_logs( + *, min_level: int = logging.INFO +) -> Iterator[list[tuple[int, str, str, str]]]: apprise_logger = logging.getLogger(_APPRISE_LOGGER_NAME) previous_level = apprise_logger.level handler = _AppriseLogCapture(thread_id=threading.get_ident()) @@ -170,7 +175,7 @@ def _log_apprise_exception_debug(*, action: str, scheme: str, exc: Exception) -> type(exc).__name__, scheme, exc, - exc_info=True, + exc_info=(type(exc), exc, exc.__traceback__), ) @@ -197,7 +202,7 @@ def _build_apprise_warning_detail( return None -def _normalize_routes(value: Any) -> list[dict[str, str]]: +def _normalize_routes(value: object) -> list[dict[str, str]]: if not isinstance(value, list): return [] @@ -248,10 +253,10 @@ def _resolve_admin_routes() -> list[dict[str, str]]: return _normalize_routes(app_config.get("ADMIN_NOTIFICATION_ROUTES", [])) -def _normalize_user_id(value: Any) -> int | None: +def _normalize_user_id(value: object) -> int | None: try: user_id = int(value) - except (TypeError, ValueError): + except TypeError, ValueError: return None if user_id < 1: return None @@ -291,7 +296,7 @@ def _resolve_route_urls_for_event( return selected -def _resolve_notify_type(event: NotificationEvent) -> Any: +def _resolve_notify_type(event: NotificationEvent) -> object: if apprise is None: fallback = { NotificationEvent.REQUEST_CREATED: "info", @@ -312,7 +317,7 @@ def _resolve_notify_type(event: NotificationEvent) -> Any: return mapping[event] -def _clean_text(value: Any, fallback: str) -> str: +def _clean_text(value: object, fallback: str) -> str: text = str(value or "").strip() return text or fallback @@ -330,7 +335,10 @@ def _render_message(context: NotificationContext) -> tuple[str, str]: if event == NotificationEvent.REQUEST_REJECTED: note = _clean_text(context.admin_note, "") note_line = f"\nNote: {note}" if note else "" - return "Request Rejected", f'Request for "{title}" by {author} was rejected.{note_line}' + return ( + "Request Rejected", + f'Request for "{title}" by {author} was rejected.{note_line}', + ) if event == NotificationEvent.DOWNLOAD_COMPLETE: return "Download Complete", f'"{title}" by {author} downloaded successfully.' @@ -339,7 +347,7 @@ def _render_message(context: NotificationContext) -> tuple[str, str]: return "Download Failed", f'Failed to download "{title}" by {author}.{error_line}' -def _plugin_label(plugin: Any, fallback_scheme: str) -> str: +def _plugin_label(plugin: object, fallback_scheme: str) -> str: """Build a human-readable label from a validated Apprise plugin. Combines the URL scheme with the plugin's service name (app_id) and @@ -351,10 +359,8 @@ def _plugin_label(plugin: Any, fallback_scheme: str) -> str: app_id = getattr(plugin, "app_id", None) if app_id and str(app_id) != fallback_scheme: privacy_url: str | None = None - try: + with suppress(Exception): privacy_url = plugin.url(privacy=True) - except Exception: - pass suffix = str(app_id) if privacy_url: @@ -369,7 +375,7 @@ def _dispatch_to_apprise( *, title: str, body: str, - notify_type: Any, + notify_type: object, ) -> dict[str, Any]: normalized_urls = _normalize_urls(list(urls)) url_schemes = _extract_url_schemes(normalized_urls) @@ -443,9 +449,7 @@ def _dispatch_to_apprise( if warning_detail: failure_details.append(warning_detail) else: - failure_details.append( - f"{scheme}: notify raised {type(exc).__name__}: {exc}" - ) + failure_details.append(f"{scheme}: notify raised {type(exc).__name__}: {exc}") continue _log_apprise_records(apprise_records) @@ -502,7 +506,7 @@ def _dispatch_to_apprise( return result -def _create_apprise_client() -> Any: +def _create_apprise_client() -> object: if apprise is None: return None @@ -535,7 +539,9 @@ def _create_apprise_client() -> Any: return apprise_cls() -def _send_admin_event(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> dict[str, Any]: +def _send_admin_event( + event: NotificationEvent, context: NotificationContext, urls: list[str] +) -> dict[str, Any]: title, body = _render_message(context) notify_type = _resolve_notify_type(event) return _dispatch_to_apprise(urls, title=title, body=body, notify_type=notify_type) @@ -554,7 +560,9 @@ def notify_admin(event: NotificationEvent, context: NotificationContext) -> None logger.warning("Failed to queue admin notification '%s': %s", event.value, exc) -def notify_user(user_id: int | None, event: NotificationEvent, context: NotificationContext) -> None: +def notify_user( + user_id: int | None, event: NotificationEvent, context: NotificationContext +) -> None: """Send a per-user notification for an event if subscribed.""" normalized_user_id = _normalize_user_id(user_id) if normalized_user_id is None: @@ -576,10 +584,16 @@ def notify_user(user_id: int | None, event: NotificationEvent, context: Notifica ) -def _dispatch_admin_async(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> None: +def _dispatch_admin_async( + event: NotificationEvent, context: NotificationContext, urls: list[str] +) -> None: result = _send_admin_event(event, context, urls) if not result.get("success", False): - logger.warning("Admin notification failed for event '%s': %s", event.value, result.get("message")) + logger.warning( + "Admin notification failed for event '%s': %s", + event.value, + result.get("message"), + ) def _dispatch_user_async( diff --git a/shelfmark/core/oidc_auth.py b/shelfmark/core/oidc_auth.py index 3f8e4266..3e8809dc 100644 --- a/shelfmark/core/oidc_auth.py +++ b/shelfmark/core/oidc_auth.py @@ -4,12 +4,15 @@ Handles group claim parsing, user info extraction, and user provisioning. Flask route handlers are registered separately in main.py. """ -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any from shelfmark.core.external_user_linking import upsert_external_user -from shelfmark.core.user_db import UserDB -def parse_group_claims(id_token: Dict[str, Any], group_claim: str) -> List[str]: +if TYPE_CHECKING: + from shelfmark.core.user_db import UserDB + + +def parse_group_claims(id_token: dict[str, Any], group_claim: str) -> list[str]: """Extract group list from an ID token claim. Supports list, comma-separated string, or pipe-separated string. @@ -26,7 +29,7 @@ def parse_group_claims(id_token: Dict[str, Any], group_claim: str) -> List[str]: return [] -def extract_user_info(id_token: Dict[str, Any]) -> Dict[str, Any]: +def extract_user_info(id_token: dict[str, Any]) -> dict[str, Any]: """Extract user info from OIDC ID token claims. Returns a dict with keys: oidc_subject, username, email, display_name. @@ -47,11 +50,12 @@ def extract_user_info(id_token: Dict[str, Any]) -> Dict[str, Any]: def provision_oidc_user( db: UserDB, - user_info: Dict[str, Any], - is_admin: Optional[bool] = None, + user_info: dict[str, Any], + *, + is_admin: bool | None = None, allow_email_link: bool = False, allow_create: bool = True, -) -> Optional[Dict[str, Any]]: +) -> dict[str, Any] | None: """Create or update a user from OIDC claims. Matching and collision handling use the shared external user linker: diff --git a/shelfmark/core/oidc_routes.py b/shelfmark/core/oidc_routes.py index f9b4d37f..388679aa 100644 --- a/shelfmark/core/oidc_routes.py +++ b/shelfmark/core/oidc_routes.py @@ -4,12 +4,12 @@ Registers /api/auth/oidc/login and /api/auth/oidc/callback endpoints. Business logic remains in oidc_auth.py. """ -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlencode, urlsplit, urlunsplit -from authlib.jose.errors import InvalidClaimError from authlib.integrations.flask_client import OAuth -from flask import Flask, jsonify, redirect, request, session +from authlib.jose.errors import InvalidClaimError +from flask import Flask, Response, jsonify, redirect, request, session from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger @@ -18,15 +18,17 @@ from shelfmark.core.oidc_auth import ( parse_group_claims, provision_oidc_user, ) -from shelfmark.core.user_db import UserDB from shelfmark.download.network import get_ssl_verify +if TYPE_CHECKING: + from shelfmark.core.user_db import UserDB + logger = setup_logger(__name__) oauth = OAuth() _RETURN_TO_SESSION_KEY = "oidc_return_to" -def _normalize_claims(raw_claims: Any) -> dict[str, Any]: +def _normalize_claims(raw_claims: object) -> dict[str, Any]: """Return a plain dict for claims from Authlib token/userinfo payloads.""" if raw_claims is None: return {} @@ -60,7 +62,7 @@ def _login_error_url(message: str) -> str: return f"{login_url}?{urlencode(params)}" -def _normalize_return_to(raw_return_to: Any) -> str | None: +def _normalize_return_to(raw_return_to: object) -> str | None: """Return a safe app-relative post-login target.""" if not isinstance(raw_return_to, str): return None @@ -79,14 +81,9 @@ def _normalize_return_to(raw_return_to: Any) -> str | None: if path == script_root: path = "/" elif path.startswith(f"{script_root}/"): - path = path[len(script_root):] or "/" + path = path[len(script_root) :] or "/" - if ( - path == "/login" - or path.startswith("/login/") - or path == "/api" - or path.startswith("/api/") - ): + if path in {"/login", "/api"} or path.startswith(("/login/", "/api/")): return None return urlunsplit(("", "", path, parsed.query, parsed.fragment)) @@ -95,9 +92,7 @@ def _normalize_return_to(raw_return_to: Any) -> str | None: def _get_pending_return_to(*, clear: bool = False) -> str | None: """Read the pending post-login target from the session.""" raw_return_to = ( - session.pop(_RETURN_TO_SESSION_KEY, None) - if clear - else session.get(_RETURN_TO_SESSION_KEY) + session.pop(_RETURN_TO_SESSION_KEY, None) if clear else session.get(_RETURN_TO_SESSION_KEY) ) normalized = _normalize_return_to(raw_return_to) if normalized is None and not clear: @@ -122,18 +117,21 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]: client_id = str(app_config.get("OIDC_CLIENT_ID", "") or "") if not discovery_url or not client_id: - raise ValueError("OIDC not configured") + msg = "OIDC not configured" + raise ValueError(msg) configured_scopes = app_config.get("OIDC_SCOPES", ["openid", "email", "profile"]) if isinstance(configured_scopes, list): scope_values = [str(scope).strip() for scope in configured_scopes if str(scope).strip()] elif isinstance(configured_scopes, str): delimiter = "," if "," in configured_scopes else " " - scope_values = [scope.strip() for scope in configured_scopes.split(delimiter) if scope.strip()] + scope_values = [ + scope.strip() for scope in configured_scopes.split(delimiter) if scope.strip() + ] else: scope_values = [] - scopes = list(dict.fromkeys(["openid"] + scope_values)) + scopes = list(dict.fromkeys(["openid", *scope_values])) admin_group = app_config.get("OIDC_ADMIN_GROUP", "") group_claim = app_config.get("OIDC_GROUP_CLAIM", "groups") @@ -141,7 +139,7 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]: if admin_group and use_admin_group and group_claim and group_claim not in scopes: scopes.append(group_claim) - def _ssl_compliance_fix(session, **kwargs): + def _ssl_compliance_fix(session: Any, **kwargs: Any) -> Any: """Set session.verify based on the Certificate Validation setting.""" session.verify = get_ssl_verify(discovery_url) return session @@ -162,7 +160,8 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]: client = oauth.create_client("shelfmark_idp") if client is None: - raise RuntimeError("OIDC client initialization failed") + msg = "OIDC client initialization failed" + raise RuntimeError(msg) return client, { "OIDC_DISCOVERY_URL": discovery_url, @@ -178,7 +177,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None: oauth.init_app(app) @app.route("/api/auth/oidc/login", methods=["GET"]) - def oidc_login(): + def oidc_login() -> Response | tuple[Response, int]: """Initiate OIDC login flow and redirect to the provider.""" try: client, _ = _get_oidc_client() @@ -191,17 +190,17 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None: return client.authorize_redirect(redirect_uri) except ValueError: return jsonify({"error": "OIDC not configured"}), 500 - except Exception as e: - logger.error(f"OIDC login error: {e}") + except Exception: + logger.exception("OIDC login error") return jsonify({"error": "OIDC login failed"}), 500 @app.route("/api/auth/oidc/callback", methods=["GET"]) - def oidc_callback(): + def oidc_callback() -> Response | tuple[Response, int]: """Handle OIDC callback from identity provider.""" try: error = request.args.get("error") if error: - logger.warning(f"OIDC callback error from IdP: {error}") + logger.warning("OIDC callback error from IdP: %s", error) return redirect(_login_error_url("Authentication failed")) client, config = _get_oidc_client() @@ -216,12 +215,14 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None: if isinstance(metadata, dict): provider_issuer = str(metadata.get("issuer", "")) except Exception as metadata_error: - logger.debug(f"OIDC metadata lookup failed during claim diagnostics: {metadata_error}") + logger.debug( + "OIDC metadata lookup failed during claim diagnostics: %s", + metadata_error, + ) - logger.error( - "OIDC callback claim validation failed: claim=%s error=%s discovery_url=%s provider_issuer=%s", + logger.exception( + "OIDC callback claim validation failed: claim=%s discovery_url=%s provider_issuer=%s", claim_name, - e, discovery_url or "", provider_issuer or "", ) @@ -232,7 +233,9 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None: ) return redirect(_login_error_url(msg)) - return redirect(_login_error_url(f"OIDC token claim validation failed: {claim_name}")) + return redirect( + _login_error_url(f"OIDC token claim validation failed: {claim_name}") + ) claims = _normalize_claims(token.get("userinfo")) # If userinfo is missing or claims are too sparse, request it explicitly. @@ -242,8 +245,8 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None: fetched_claims = _normalize_claims(client.userinfo(token=token)) except TypeError: fetched_claims = _normalize_claims(client.userinfo()) - except Exception as e: - logger.error(f"Failed to fetch OIDC userinfo: {e}") + except Exception: + logger.exception("Failed to fetch OIDC userinfo") if fetched_claims: claims = {**claims, **fetched_claims} @@ -274,7 +277,8 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None: ) if user is None: logger.warning( - f"OIDC login rejected: auto-provision disabled for {user_info['username']}" + "OIDC login rejected: auto-provision disabled for %s", + user_info["username"], ) return redirect(_login_error_url("Account not found. Contact your administrator.")) @@ -283,12 +287,12 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None: session["db_user_id"] = user["id"] session.permanent = True - logger.info(f"OIDC login successful: {user['username']} (admin={is_admin})") + logger.info("OIDC login successful: %s (admin=%s)", user["username"], is_admin) return redirect(_post_login_redirect_target(_get_pending_return_to(clear=True))) except ValueError as e: - logger.error(f"OIDC callback error: {e}") + logger.exception("OIDC callback error") return redirect(_login_error_url(str(e))) - except Exception as e: - logger.error(f"OIDC callback error: {e}") + except Exception: + logger.exception("OIDC callback error") return redirect(_login_error_url("Authentication failed")) diff --git a/shelfmark/core/onboarding.py b/shelfmark/core/onboarding.py index 0258fc90..51ae2ff0 100644 --- a/shelfmark/core/onboarding.py +++ b/shelfmark/core/onboarding.py @@ -1,5 +1,4 @@ -""" -Onboarding wizard configuration. +"""Onboarding wizard configuration. Defines the steps and fields for the first-run onboarding experience. Reuses field definitions from the settings registry where possible. @@ -8,16 +7,16 @@ Reuses field definitions from the settings registry where possible. import json from dataclasses import replace from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any from shelfmark.core.logger import setup_logger from shelfmark.core.settings_registry import ( HeadingField, SettingsField, - get_settings_tab, - serialize_field, - save_config_file, get_setting_value, + get_settings_tab, + save_config_file, + serialize_field, ) logger = setup_logger(__name__) @@ -29,6 +28,7 @@ ONBOARDING_STORAGE_KEY = "onboarding_complete" def _get_config_dir() -> Path: """Get the config directory path.""" from shelfmark.config.env import CONFIG_DIR + return Path(CONFIG_DIR) @@ -45,11 +45,11 @@ def is_onboarding_complete() -> bool: return False try: - with open(config_file, 'r') as f: + with config_file.open() as f: config = json.load(f) return config.get(ONBOARDING_STORAGE_KEY, False) except (json.JSONDecodeError, OSError) as e: - logger.warning(f"Could not read onboarding status from settings.json: {e}") + logger.warning("Could not read onboarding status from settings.json: %s", e) return False @@ -57,14 +57,13 @@ def mark_onboarding_complete() -> bool: """Mark onboarding as complete.""" try: return save_config_file("general", {ONBOARDING_STORAGE_KEY: True}) - except Exception as e: - logger.error(f"Failed to mark onboarding complete: {e}") + except Exception: + logger.exception("Failed to mark onboarding complete") return False -def _get_field_from_tab(tab_name: str, field_key: str) -> Optional[SettingsField]: - """ - Extract a specific field from a registered settings tab. +def _get_field_from_tab(tab_name: str, field_key: str) -> SettingsField | None: + """Extract a specific field from a registered settings tab. Args: tab_name: Name of the settings tab (e.g., 'search_mode', 'hardcover') @@ -72,23 +71,23 @@ def _get_field_from_tab(tab_name: str, field_key: str) -> Optional[SettingsField Returns: The field if found, None otherwise + """ tab = get_settings_tab(tab_name) if not tab: - logger.warning(f"Settings tab not found: {tab_name}") + logger.warning("Settings tab not found: %s", tab_name) return None for field in tab.fields: - if hasattr(field, 'key') and field.key == field_key: + if hasattr(field, "key") and field.key == field_key: return field - logger.warning(f"Field {field_key} not found in tab {tab_name}") + logger.warning("Field %s not found in tab %s", field_key, tab_name) return None def _clone_field_with_overrides(field: SettingsField, **overrides) -> SettingsField: - """ - Clone a field with optional attribute overrides. + """Clone a field with optional attribute overrides. Useful for customizing labels, descriptions, or defaults for onboarding context. """ @@ -100,9 +99,9 @@ def _clone_field_with_overrides(field: SettingsField, **overrides) -> SettingsFi # ============================================================================= -def get_search_mode_fields() -> List[SettingsField]: +def get_search_mode_fields() -> list[SettingsField]: """Step 1: Choose search mode - uses actual SEARCH_MODE field from settings.""" - fields: List[SettingsField] = [ + fields: list[SettingsField] = [ HeadingField( key="welcome_heading", title="Welcome to Shelfmark", @@ -114,17 +113,19 @@ def get_search_mode_fields() -> List[SettingsField]: search_mode_field = _get_field_from_tab("search_mode", "SEARCH_MODE") if search_mode_field: # Clone with onboarding-specific description - fields.append(_clone_field_with_overrides( - search_mode_field, - description="Choose how you want to find books.", - )) + fields.append( + _clone_field_with_overrides( + search_mode_field, + description="Choose how you want to find books.", + ) + ) return fields -def get_metadata_provider_fields() -> List[SettingsField]: +def get_metadata_provider_fields() -> list[SettingsField]: """Step 2: Choose metadata provider - uses actual METADATA_PROVIDER field.""" - fields: List[SettingsField] = [ + fields: list[SettingsField] = [ HeadingField( key="metadata_heading", title="Metadata Provider", @@ -155,18 +156,20 @@ def get_metadata_provider_fields() -> List[SettingsField]: ] # Clone with onboarding-specific options and default - fields.append(_clone_field_with_overrides( - provider_field, - default="hardcover", - options=onboarding_options, - )) + fields.append( + _clone_field_with_overrides( + provider_field, + default="hardcover", + options=onboarding_options, + ) + ) return fields -def get_hardcover_setup_fields() -> List[SettingsField]: +def get_hardcover_setup_fields() -> list[SettingsField]: """Step 3a: Configure Hardcover - uses actual API key and test connection fields.""" - fields: List[SettingsField] = [ + fields: list[SettingsField] = [ HeadingField( key="hardcover_setup_heading", title="Hardcover Setup", @@ -189,9 +192,9 @@ def get_hardcover_setup_fields() -> List[SettingsField]: return fields -def get_googlebooks_setup_fields() -> List[SettingsField]: +def get_googlebooks_setup_fields() -> list[SettingsField]: """Step 3b: Configure Google Books - uses actual API key and test connection fields.""" - fields: List[SettingsField] = [ + fields: list[SettingsField] = [ HeadingField( key="googlebooks_setup_heading", title="Google Books Setup", @@ -214,9 +217,9 @@ def get_googlebooks_setup_fields() -> List[SettingsField]: return fields -def get_prowlarr_fields() -> List[SettingsField]: +def get_prowlarr_fields() -> list[SettingsField]: """Step 4: Configure Prowlarr connection - uses actual Prowlarr fields.""" - fields: List[SettingsField] = [ + fields: list[SettingsField] = [ HeadingField( key="prowlarr_heading", title="Prowlarr Integration (Optional)", @@ -234,9 +237,9 @@ def get_prowlarr_fields() -> List[SettingsField]: return fields -def get_prowlarr_indexers_fields() -> List[SettingsField]: +def get_prowlarr_indexers_fields() -> list[SettingsField]: """Step 5: Select Prowlarr indexers to search.""" - fields: List[SettingsField] = [ + fields: list[SettingsField] = [ HeadingField( key="prowlarr_indexers_heading", title="Select Indexers", @@ -316,10 +319,8 @@ ONBOARDING_STEPS = [ ] -def get_onboarding_config() -> Dict[str, Any]: - """ - Get the full onboarding configuration including steps and current values. - """ +def get_onboarding_config() -> dict[str, Any]: + """Get the full onboarding configuration including steps and current values.""" steps = [] all_values = {} @@ -334,9 +335,11 @@ def get_onboarding_config() -> Dict[str, Any]: serialized_fields.append(serialized) # Collect values (skip HeadingFields) - if hasattr(field, 'key') and field.key and not isinstance(field, HeadingField): + if hasattr(field, "key") and field.key and not isinstance(field, HeadingField): value = get_setting_value(field, tab_name) - all_values[field.key] = value if value is not None else getattr(field, 'default', '') + all_values[field.key] = ( + value if value is not None else getattr(field, "default", "") + ) step = { "id": step_config["id"], @@ -359,19 +362,19 @@ def get_onboarding_config() -> Dict[str, Any]: } -def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]: - """ - Save onboarding settings and mark as complete. +def save_onboarding_settings(values: dict[str, Any]) -> dict[str, Any]: + """Save onboarding settings and mark as complete. Args: values: Dict of field key -> value Returns: Dict with success status and message + """ try: # Group values by their target tab - tab_values: Dict[str, Dict[str, Any]] = {} + tab_values: dict[str, dict[str, Any]] = {} for step_config in ONBOARDING_STEPS: tab_name = step_config["tab"] @@ -391,7 +394,7 @@ def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]: for tab_name, tab_data in tab_values.items(): if tab_data: save_config_file(tab_name, tab_data) - logger.info(f"Saved onboarding settings to {tab_name}: {list(tab_data.keys())}") + logger.info("Saved onboarding settings to %s: %s", tab_name, list(tab_data.keys())) # Enable the selected metadata provider search_mode = values.get("SEARCH_MODE", "direct") @@ -416,7 +419,11 @@ def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]: provider_config["GOOGLEBOOKS_API_KEY"] = values["GOOGLEBOOKS_API_KEY"] save_config_file(provider, provider_config) - logger.info(f"Enabled metadata provider: {provider} with keys: {list(provider_config.keys())}") + logger.info( + "Enabled metadata provider: %s with keys: %s", + provider, + list(provider_config.keys()), + ) # Mark onboarding as complete mark_onboarding_complete() @@ -424,12 +431,13 @@ def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]: # Refresh config try: from shelfmark.core.config import config + config.refresh() except ImportError as e: - logger.debug(f"Could not refresh config after onboarding: {e}") - - return {"success": True, "message": "Onboarding complete!"} + logger.debug("Could not refresh config after onboarding: %s", e) except Exception as e: - logger.error(f"Failed to save onboarding settings: {e}") + logger.exception("Failed to save onboarding settings") return {"success": False, "message": str(e)} + else: + return {"success": True, "message": "Onboarding complete!"} diff --git a/shelfmark/core/path_mappings.py b/shelfmark/core/path_mappings.py index 2b9fb22a..afe8ca2a 100644 --- a/shelfmark/core/path_mappings.py +++ b/shelfmark/core/path_mappings.py @@ -11,7 +11,12 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Optional +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + +_WINDOWS_DRIVE_PREFIX_LENGTH = 2 @dataclass(frozen=True) @@ -36,14 +41,14 @@ def _normalize_prefix(path: str) -> str: def _is_windows_path(path: str) -> bool: """Check if a path looks like a Windows path (has a drive letter like C:/).""" - return len(path) >= 2 and path[1] == ":" and path[0].isalpha() + return len(path) >= _WINDOWS_DRIVE_PREFIX_LENGTH and path[1] == ":" and path[0].isalpha() def _normalize_host(host: str) -> str: return str(host or "").strip().lower() -def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]: +def parse_remote_path_mappings(value: object) -> list[RemotePathMapping]: if not value or not isinstance(value, list): return [] @@ -60,7 +65,9 @@ def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]: if not host or not remote_path or not local_path: continue - mappings.append(RemotePathMapping(host=host, remote_path=remote_path, local_path=local_path)) + mappings.append( + RemotePathMapping(host=host, remote_path=remote_path, local_path=local_path) + ) mappings.sort(key=lambda m: len(m.remote_path), reverse=True) return mappings @@ -96,16 +103,17 @@ def remap_remote_to_local_with_match( prefix_lower = remote_prefix.lower() matches = remote_lower == prefix_lower or remote_lower.startswith(prefix_lower + "/") else: - matches = remote_normalized == remote_prefix or remote_normalized.startswith(remote_prefix + "/") + matches = remote_normalized == remote_prefix or remote_normalized.startswith( + remote_prefix + "/" + ) if matches: # Use the length of the original prefix to extract remainder # This preserves the original case in folder names - remainder = remote_normalized[len(remote_prefix):] + remainder = remote_normalized[len(remote_prefix) :] local_prefix = _normalize_prefix(mapping.local_path) - if remainder.startswith("/"): - remainder = remainder[1:] + remainder = remainder.removeprefix("/") remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix) return remapped, True @@ -113,7 +121,9 @@ def remap_remote_to_local_with_match( return Path(remote_normalized), False -def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path) -> Path: +def remap_remote_to_local( + *, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path +) -> Path: remapped, _ = remap_remote_to_local_with_match( mappings=mappings, host=host, @@ -122,13 +132,12 @@ def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, r return remapped -def get_client_host_identifier(client: Any) -> Optional[str]: +def get_client_host_identifier(client: object) -> str | None: """Return a stable identifier used by the mapping UI. Sonarr uses the download client's configured host. Shelfmark currently uses the download client 'name' (e.g. qbittorrent, sabnzbd). """ - name = getattr(client, "name", None) if isinstance(name, str) and name.strip(): return name.strip().lower() diff --git a/shelfmark/core/prefix_middleware.py b/shelfmark/core/prefix_middleware.py index 80684a7a..94868779 100644 --- a/shelfmark/core/prefix_middleware.py +++ b/shelfmark/core/prefix_middleware.py @@ -2,18 +2,26 @@ from __future__ import annotations -from typing import Iterable, Optional +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable class PrefixMiddleware: """Strip a configured URL prefix from PATH_INFO before routing.""" - def __init__(self, app, prefix: str, bypass_paths: Optional[Iterable[str]] = None) -> None: + def __init__( + self, + app: Callable[[dict[str, object], Callable[..., object]], object], + prefix: str, + bypass_paths: Iterable[str] | None = None, + ) -> None: self.app = app self.prefix = prefix.rstrip("/") self.bypass_paths = set(bypass_paths or []) - def __call__(self, environ, start_response): + def __call__(self, environ: dict[str, object], start_response: Callable[..., object]) -> object: path = environ.get("PATH_INFO", "") or "" if path in self.bypass_paths: @@ -24,7 +32,7 @@ class PrefixMiddleware: if path == self.prefix or path.startswith(self.prefix + "/"): environ["SCRIPT_NAME"] = self.prefix - environ["PATH_INFO"] = path[len(self.prefix):] or "/" + environ["PATH_INFO"] = path[len(self.prefix) :] or "/" return self.app(environ, start_response) start_response("404 Not Found", [("Content-Type", "text/plain")]) diff --git a/shelfmark/core/queue.py b/shelfmark/core/queue.py index 8fd5f11b..254e4898 100644 --- a/shelfmark/core/queue.py +++ b/shelfmark/core/queue.py @@ -4,12 +4,20 @@ import queue import time from datetime import datetime, timedelta from pathlib import Path -from threading import Lock, Event -from typing import Dict, List, Optional, Tuple, Any, Callable +from threading import Event, Lock +from typing import TYPE_CHECKING, Any from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import QueueStatus, QueueItem, DownloadTask, TERMINAL_QUEUE_STATUSES +from shelfmark.core.models import ( + TERMINAL_QUEUE_STATUSES, + DownloadTask, + QueueItem, + QueueStatus, +) + +if TYPE_CHECKING: + from collections.abc import Callable logger = setup_logger(__name__) @@ -25,10 +33,8 @@ class BookQueue: self._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads self._active_downloads: dict[str, bool] = {} # Track currently downloading tasks - self._terminal_status_hook: Optional[ - Callable[[str, QueueStatus, DownloadTask], None] - ] = None - self._queue_hook: Optional[Callable[[str, DownloadTask], None]] = None + self._terminal_status_hook: Callable[[str, QueueStatus, DownloadTask], None] | None = None + self._queue_hook: Callable[[str, DownloadTask], None] | None = None @property def _status_timeout(self) -> timedelta: @@ -37,12 +43,15 @@ class BookQueue: def add(self, task: DownloadTask) -> bool: """Add a download task to the queue. Returns False if already exists.""" - hook: Optional[Callable[[str, DownloadTask], None]] = None + hook: Callable[[str, DownloadTask], None] | None = None with self._lock: task_id = task.task_id # Don't add if already exists and not in error/cancelled state - if task_id in self._status and self._status[task_id] not in [QueueStatus.ERROR, QueueStatus.CANCELLED]: + if task_id in self._status and self._status[task_id] not in [ + QueueStatus.ERROR, + QueueStatus.CANCELLED, + ]: return False # Ensure added_time is set @@ -62,7 +71,7 @@ class BookQueue: logger.warning("Queue hook failed while adding task %s: %s", task_id, exc) return True - def get_next(self) -> Optional[Tuple[str, Event]]: + def get_next(self) -> tuple[str, Event] | None: """Get next task ID from queue with cancellation flag.""" # Use iterative approach to avoid stack overflow if many items are cancelled while True: @@ -79,17 +88,17 @@ class BookQueue: cancel_flag = Event() self._cancel_flags[task_id] = cancel_flag self._active_downloads[task_id] = True - - return task_id, cancel_flag except queue.Empty: return None + else: + return task_id, cancel_flag - def get_task(self, task_id: str) -> Optional[DownloadTask]: + def get_task(self, task_id: str) -> DownloadTask | None: """Get a task by its ID.""" with self._lock: return self._task_data.get(task_id) - def get_task_status(self, task_id: str) -> Optional[QueueStatus]: + def get_task_status(self, task_id: str) -> QueueStatus | None: """Get queue status for a task id.""" with self._lock: return self._status.get(task_id) @@ -101,7 +110,7 @@ class BookQueue: def set_terminal_status_hook( self, - hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]], + hook: Callable[[str, QueueStatus, DownloadTask], None] | None, ) -> None: """Register a callback invoked when a task first enters a terminal status.""" with self._lock: @@ -109,7 +118,7 @@ class BookQueue: def set_queue_hook( self, - hook: Optional[Callable[[str, DownloadTask], None]], + hook: Callable[[str, DownloadTask], None] | None, ) -> None: """Register a callback invoked when a task is added to the queue.""" with self._lock: @@ -117,8 +126,8 @@ class BookQueue: def update_status(self, book_id: str, status: QueueStatus) -> None: """Update status of a book in the queue.""" - hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]] = None - hook_task: Optional[DownloadTask] = None + hook: Callable[[str, QueueStatus, DownloadTask], None] | None = None + hook_task: DownloadTask | None = None with self._lock: previous_status = self._status.get(book_id) self._update_status(book_id, status) @@ -159,16 +168,19 @@ class BookQueue: if task_id in self._task_data: self._task_data[task_id].status_message = message - def get_status(self, user_id: Optional[int] = None) -> Dict[QueueStatus, Dict[str, DownloadTask]]: + def get_status(self, user_id: int | None = None) -> dict[QueueStatus, dict[str, DownloadTask]]: """Get current queue status grouped by status. Args: user_id: If provided, only return tasks belonging to this user. If None, return all. + """ self.refresh() with self._lock: - result: Dict[QueueStatus, Dict[str, DownloadTask]] = {status: {} for status in QueueStatus} + result: dict[QueueStatus, dict[str, DownloadTask]] = { + status: {} for status in QueueStatus + } for task_id, status in self._status.items(): if task_id in self._task_data: task = self._task_data[task_id] @@ -177,7 +189,7 @@ class BookQueue: result[status][task_id] = task return result - def get_queue_order(self) -> List[Dict[str, Any]]: + def get_queue_order(self) -> list[dict[str, Any]]: """Get current queue order for display.""" with self._lock: queue_items = [] @@ -185,39 +197,42 @@ class BookQueue: # Get items from priority queue without removing them temp_items = [] while not self._queue.empty(): - try: - item = self._queue.get_nowait() - temp_items.append(item) - task_id = item.book_id # QueueItem uses book_id as the ID field - if task_id in self._task_data: - task = self._task_data[task_id] - queue_items.append({ - 'id': task_id, - 'title': task.title, - 'author': task.author, - 'priority': item.priority, - 'added_time': item.added_time, - 'status': self._status.get(task_id, QueueStatus.QUEUED) - }) - except queue.Empty: - break + item = self._queue.get_nowait() + temp_items.append(item) + task_id = item.book_id # QueueItem uses book_id as the ID field + if task_id in self._task_data: + task = self._task_data[task_id] + queue_items.append( + { + "id": task_id, + "title": task.title, + "author": task.author, + "priority": item.priority, + "added_time": item.added_time, + "status": self._status.get(task_id, QueueStatus.QUEUED), + } + ) # Put items back in queue for item in temp_items: self._queue.put(item) - return sorted(queue_items, key=lambda x: (x['priority'], x['added_time'])) + return sorted(queue_items, key=lambda x: (x["priority"], x["added_time"])) def cancel_download(self, task_id: str) -> bool: """Cancel an active or queued download.""" with self._lock: current_status = self._status.get(task_id) - if current_status in [QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING]: + if current_status in [ + QueueStatus.RESOLVING, + QueueStatus.LOCATING, + QueueStatus.DOWNLOADING, + ]: # Signal active download to stop if task_id in self._cancel_flags: self._cancel_flags[task_id].set() - elif current_status not in [QueueStatus.QUEUED]: + elif current_status != QueueStatus.QUEUED: # Not in a cancellable state return False @@ -235,20 +250,17 @@ class BookQueue: found = False while not self._queue.empty(): - try: - item = self._queue.get_nowait() - if item.book_id == task_id: # QueueItem uses book_id as the ID field - # Create new item with updated priority - new_item = QueueItem(task_id, new_priority, item.added_time) - temp_items.append(new_item) - found = True - # Update task data priority - if task_id in self._task_data: - self._task_data[task_id].priority = new_priority - else: - temp_items.append(item) - except queue.Empty: - break + item = self._queue.get_nowait() + if item.book_id == task_id: # QueueItem uses book_id as the ID field + # Create new item with updated priority + new_item = QueueItem(task_id, new_priority, item.added_time) + temp_items.append(new_item) + found = True + # Update task data priority + if task_id in self._task_data: + self._task_data[task_id].priority = new_priority + else: + temp_items.append(item) # Put all items back for item in temp_items: @@ -256,13 +268,13 @@ class BookQueue: return found - def enqueue_existing(self, task_id: str, *, priority: Optional[int] = None) -> bool: + def enqueue_existing(self, task_id: str, *, priority: int | None = None) -> bool: """Requeue an existing task regardless of current status. This is used for retries where task metadata should be preserved. """ - hook: Optional[Callable[[str, DownloadTask], None]] = None - hook_task: Optional[DownloadTask] = None + hook: Callable[[str, DownloadTask], None] | None = None + hook_task: DownloadTask | None = None with self._lock: task = self._task_data.get(task_id) if task is None: @@ -278,10 +290,7 @@ class BookQueue: # De-duplicate queue entries for this task id. temp_items: list[QueueItem] = [] while not self._queue.empty(): - try: - item = self._queue.get_nowait() - except queue.Empty: - break + item = self._queue.get_nowait() if item.book_id != task_id: temp_items.append(item) @@ -301,25 +310,22 @@ class BookQueue: logger.warning("Queue hook failed while requeueing task %s: %s", task_id, exc) return True - def reorder_queue(self, task_priorities: Dict[str, int]) -> bool: + def reorder_queue(self, task_priorities: dict[str, int]) -> bool: """Bulk reorder queue by mapping task_id to new priority.""" with self._lock: # Extract all items from queue all_items = [] while not self._queue.empty(): - try: - item = self._queue.get_nowait() - task_id = item.book_id # QueueItem uses book_id as the ID field - # Update priority if specified - if task_id in task_priorities: - new_priority = task_priorities[task_id] - item = QueueItem(task_id, new_priority, item.added_time) - # Update task data priority - if task_id in self._task_data: - self._task_data[task_id].priority = new_priority - all_items.append(item) - except queue.Empty: - break + item = self._queue.get_nowait() + task_id = item.book_id # QueueItem uses book_id as the ID field + # Update priority if specified + if task_id in task_priorities: + new_priority = task_priorities[task_id] + item = QueueItem(task_id, new_priority, item.added_time) + # Update task data priority + if task_id in self._task_data: + self._task_data[task_id].priority = new_priority + all_items.append(item) # Put all items back with updated priorities for item in all_items: @@ -327,7 +333,7 @@ class BookQueue: return True - def get_active_downloads(self) -> List[str]: + def get_active_downloads(self) -> list[str]: """Get list of currently active download task IDs.""" with self._lock: return list(self._active_downloads.keys()) @@ -357,9 +363,12 @@ class BookQueue: # Check for stale status entries last_update = self._status_timestamps.get(task_id) - if last_update and (current_time - last_update) > self._status_timeout: - if status in terminal_statuses: - to_remove.append(task_id) + if ( + last_update + and (current_time - last_update) > self._status_timeout + and status in terminal_statuses + ): + to_remove.append(task_id) # Remove stale entries for task_id in to_remove: @@ -367,5 +376,6 @@ class BookQueue: self._status_timestamps.pop(task_id, None) self._task_data.pop(task_id, None) + # Global instance of BookQueue book_queue = BookQueue() diff --git a/shelfmark/core/request_helpers.py b/shelfmark/core/request_helpers.py index 08c0d8e7..4ac93995 100644 --- a/shelfmark/core/request_helpers.py +++ b/shelfmark/core/request_helpers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from shelfmark.core.config import config as app_config @@ -13,11 +13,11 @@ _logger = setup_logger(__name__) def now_utc_iso() -> str: """Return the current UTC time as a seconds-precision ISO 8601 string.""" - return datetime.now(timezone.utc).isoformat(timespec="seconds") + return datetime.now(UTC).isoformat(timespec="seconds") def emit_ws_event( - ws_manager: Any, + ws_manager: object, *, event_name: str, payload: dict[str, Any], @@ -33,20 +33,22 @@ def emit_ws_event( return socketio.emit(event_name, payload, to=room) except Exception as exc: - _logger.warning("Failed to emit WebSocket event '%s' to room '%s': %s", event_name, room, exc) + _logger.warning( + "Failed to emit WebSocket event '%s' to room '%s': %s", + event_name, + room, + exc, + ) def load_users_request_policy_settings() -> dict[str, Any]: """Load global request-policy settings from the users config file.""" from shelfmark.core.request_policy import REQUEST_POLICY_KEYS - return { - key: app_config.get(key) - for key in REQUEST_POLICY_KEYS - } + return {key: app_config.get(key) for key in REQUEST_POLICY_KEYS} -def coerce_bool(value: Any, default: bool = False) -> bool: +def coerce_bool(value: object, *, default: bool = False) -> bool: """Coerce arbitrary values into booleans with string-friendly semantics.""" if isinstance(value, bool): return value @@ -61,24 +63,24 @@ def coerce_bool(value: Any, default: bool = False) -> bool: return bool(value) -def get_session_db_user_id(session_obj: Any) -> int | None: +def get_session_db_user_id(session_obj: object) -> int | None: """Extract and coerce `db_user_id` from a Flask session to ``int | None``.""" raw = session_obj.get("db_user_id") if session_obj is not None else None try: return int(raw) if raw is not None else None - except (TypeError, ValueError): + except TypeError, ValueError: return None -def coerce_int(value: Any, default: int) -> int: +def coerce_int(value: object, default: int) -> int: """Best-effort integer coercion with fallback to default.""" try: return int(value) - except (TypeError, ValueError): + except TypeError, ValueError: return default -def normalize_optional_text(value: Any) -> str | None: +def normalize_optional_text(value: object) -> str | None: """Return a trimmed string or None for empty/non-string input.""" if not isinstance(value, str): return None @@ -86,16 +88,16 @@ def normalize_optional_text(value: Any) -> str | None: return normalized or None -def normalize_positive_int(value: Any) -> int | None: +def normalize_positive_int(value: object) -> int | None: """Parse *value* as a positive integer, returning ``None`` on failure.""" try: parsed = int(value) - except (TypeError, ValueError): + except TypeError, ValueError: return None return parsed if parsed > 0 else None -def normalize_optional_positive_int(value: Any, field_name: str = "value") -> int | None: +def normalize_optional_positive_int(value: object, field_name: str = "value") -> int | None: """Parse *value* as a positive integer or ``None``. Raises ``ValueError`` when *value* is present but not a valid @@ -106,13 +108,15 @@ def normalize_optional_positive_int(value: Any, field_name: str = "value") -> in try: parsed = int(value) except (TypeError, ValueError) as exc: - raise ValueError(f"{field_name} must be a positive integer when provided") from exc + msg = f"{field_name} must be a positive integer when provided" + raise ValueError(msg) from exc if parsed < 1: - raise ValueError(f"{field_name} must be a positive integer when provided") + msg = f"{field_name} must be a positive integer when provided" + raise ValueError(msg) return parsed -def populate_request_usernames(rows: list[dict[str, Any]], user_db: Any) -> None: +def populate_request_usernames(rows: list[dict[str, Any]], user_db: object) -> None: """Add 'username' to each request row by looking up user_id.""" cache: dict[int, str] = {} for row in rows: @@ -123,7 +127,7 @@ def populate_request_usernames(rows: list[dict[str, Any]], user_db: Any) -> None row["username"] = cache[requester_id] -def extract_release_source_id(release_data: Any) -> str | None: +def extract_release_source_id(release_data: object) -> str | None: """Extract and normalize release_data.source_id.""" if not isinstance(release_data, dict): return None diff --git a/shelfmark/core/request_policy.py b/shelfmark/core/request_policy.py index 7744b15f..3aecec4f 100644 --- a/shelfmark/core/request_policy.py +++ b/shelfmark/core/request_policy.py @@ -6,11 +6,12 @@ routes/services and tested independently. from __future__ import annotations -from enum import Enum -from typing import Any, Iterable, Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence +from enum import StrEnum +from typing import Any -class PolicyMode(str, Enum): +class PolicyMode(StrEnum): """Allowed request-policy modes. Ordered from most to least permissive. The content-type default acts as a @@ -33,7 +34,9 @@ _MODE_PERMISSIVENESS: dict[PolicyMode, int] = { } # Modes allowed in REQUEST_POLICY_RULES matrix rows. -MATRIX_ALLOWED_MODES = frozenset({PolicyMode.DOWNLOAD, PolicyMode.REQUEST_RELEASE, PolicyMode.BLOCKED}) +MATRIX_ALLOWED_MODES = frozenset( + {PolicyMode.DOWNLOAD, PolicyMode.REQUEST_RELEASE, PolicyMode.BLOCKED} +) def cap_mode(mode: PolicyMode, ceiling: PolicyMode) -> PolicyMode: @@ -48,6 +51,7 @@ def _source_results_are_releases(source: Any) -> bool: if normalized_source in {"", "*"}: return False from shelfmark.release_sources import source_results_are_releases + return source_results_are_releases(normalized_source) @@ -105,7 +109,9 @@ def merge_request_policy_settings( (source, content_type): (source, content_type, mode) for source, content_type, mode in global_rules } - for source, content_type, mode in _iter_rules(user_filtered.get("REQUEST_POLICY_RULES", [])): + for source, content_type, mode in _iter_rules( + user_filtered.get("REQUEST_POLICY_RULES", []) + ): merged_rules[(source, content_type)] = (source, content_type, mode) merged["REQUEST_POLICY_RULES"] = [ {"source": source, "content_type": content_type, "mode": mode.value} @@ -181,7 +187,7 @@ def get_source_content_type_capabilities() -> dict[str, set[str]]: """Return source -> supported content type map from registered sources.""" try: from shelfmark.release_sources import list_available_sources - except Exception: + except ImportError: return {} capabilities: dict[str, set[str]] = {} @@ -219,9 +225,15 @@ def validate_policy_rules( - known source names - source/content-type compatibility from source declarations """ - capabilities = source_capabilities if source_capabilities is not None else get_source_content_type_capabilities() + capabilities = ( + source_capabilities + if source_capabilities is not None + else get_source_content_type_capabilities() + ) normalized_capabilities = { - normalize_source(source): {normalize_content_type(content_type) for content_type in content_types} + normalize_source(source): { + normalize_content_type(content_type) for content_type in content_types + } for source, content_types in capabilities.items() } @@ -248,26 +260,24 @@ def validate_policy_rules( if source is None: errors.append(f"{row_label}: source is required") continue - if ( - raw_content_type is None - or (isinstance(raw_content_type, str) and not raw_content_type.strip()) + if raw_content_type is None or ( + isinstance(raw_content_type, str) and not raw_content_type.strip() ): errors.append(f"{row_label}: content_type is required") continue if content_type is None: errors.append(f"{row_label}: invalid content_type '{rule.get('content_type')}'") continue - if ( - raw_mode is None - or (isinstance(raw_mode, str) and not raw_mode.strip()) - ): + if raw_mode is None or (isinstance(raw_mode, str) and not raw_mode.strip()): errors.append(f"{row_label}: mode is required") continue if mode is None: errors.append(f"{row_label}: invalid mode '{rule.get('mode')}'") continue if mode not in MATRIX_ALLOWED_MODES: - errors.append(f"{row_label}: mode '{mode.value}' is not allowed in matrix rules (use content-type defaults instead)") + errors.append( + f"{row_label}: mode '{mode.value}' is not allowed in matrix rules (use content-type defaults instead)" + ) continue if source != "*" and source not in normalized_capabilities: @@ -340,7 +350,6 @@ def resolve_policy_mode( - sources whose browse results are already concrete releases normalize request_book to request_release. """ - effective = merge_request_policy_settings(global_settings, user_settings) normalized_source = normalize_source(source) normalized_content_type = normalize_content_type(content_type) diff --git a/shelfmark/core/request_routes.py b/shelfmark/core/request_routes.py index 78f58c23..46371642 100644 --- a/shelfmark/core/request_routes.py +++ b/shelfmark/core/request_routes.py @@ -2,14 +2,29 @@ from __future__ import annotations -from typing import Any, Callable +from typing import TYPE_CHECKING, Any -from flask import Flask, jsonify, request, session +from flask import Flask, Response, jsonify, request, session from shelfmark.core.logger import setup_logger +from shelfmark.core.notifications import ( + NotificationContext, + NotificationEvent, + notify_admin, + notify_user, +) +from shelfmark.core.request_helpers import ( + coerce_bool, + coerce_int, + emit_ws_event, + load_users_request_policy_settings, + normalize_optional_text, + normalize_positive_int, + populate_request_usernames, +) from shelfmark.core.request_policy import ( - PolicyMode, REQUEST_POLICY_DEFAULT_FALLBACK_MODE, + PolicyMode, get_source_content_type_capabilities, merge_request_policy_settings, normalize_content_type, @@ -26,22 +41,11 @@ from shelfmark.core.requests_service import ( fulfil_request, reject_request, ) -from shelfmark.core.notifications import ( - NotificationContext, - NotificationEvent, - notify_admin, - notify_user, -) -from shelfmark.core.request_helpers import ( - coerce_bool, - coerce_int, - emit_ws_event, - load_users_request_policy_settings, - normalize_optional_text, - normalize_positive_int, - populate_request_usernames, -) -from shelfmark.core.user_db import UserDB + +if TYPE_CHECKING: + from collections.abc import Callable + + from shelfmark.core.user_db import UserDB logger = setup_logger(__name__) @@ -52,7 +56,7 @@ def _error_response( *, code: str | None = None, required_mode: str | None = None, -): +) -> tuple[Response, int]: payload: dict[str, Any] = {"error": message} if code is not None: payload["code"] = code @@ -61,7 +65,9 @@ def _error_response( return jsonify(payload), status_code -def _require_request_endpoints_available(resolve_auth_mode: Callable[[], str]): +def _require_request_endpoints_available( + resolve_auth_mode: Callable[[], str], +) -> tuple[Response, int] | None: auth_mode = resolve_auth_mode() if auth_mode == "none": return _error_response( @@ -74,7 +80,7 @@ def _require_request_endpoints_available(resolve_auth_mode: Callable[[], str]): return None -def _require_db_user_id() -> tuple[int | None, Any | None]: +def _require_db_user_id() -> tuple[int | None, object | None]: raw_user_id = session.get("db_user_id") if raw_user_id is None: return None, _error_response( @@ -84,7 +90,7 @@ def _require_db_user_id() -> tuple[int | None, Any | None]: ) try: return int(raw_user_id), None - except (TypeError, ValueError): + except TypeError, ValueError: return None, _error_response( "User identity is unavailable for request workflow", 403, @@ -92,7 +98,7 @@ def _require_db_user_id() -> tuple[int | None, Any | None]: ) -def _require_admin_user_id() -> tuple[int | None, Any | None]: +def _require_admin_user_id() -> tuple[int | None, object | None]: if not session.get("is_admin", False): return None, (jsonify({"error": "Admin access required"}), 403) raw_admin_id = session.get("db_user_id") @@ -100,7 +106,7 @@ def _require_admin_user_id() -> tuple[int | None, Any | None]: return None, (jsonify({"error": "Admin user identity unavailable"}), 403) try: return int(raw_admin_id), None - except (TypeError, ValueError): + except TypeError, ValueError: return None, (jsonify({"error": "Admin user identity unavailable"}), 403) @@ -112,11 +118,11 @@ def _resolve_effective_policy( global_settings = load_users_request_policy_settings() user_settings = user_db.get_user_settings(db_user_id) if db_user_id is not None else {} effective = merge_request_policy_settings(global_settings, user_settings) - requests_enabled = coerce_bool(effective.get("REQUESTS_ENABLED"), False) + requests_enabled = coerce_bool(effective.get("REQUESTS_ENABLED"), default=False) return global_settings, user_settings, effective, requests_enabled -def _resolve_title_from_book_data(book_data: Any) -> str: +def _resolve_title_from_book_data(book_data: object) -> str: if isinstance(book_data, dict): title = normalize_optional_text(book_data.get("title")) if title is not None: @@ -124,7 +130,7 @@ def _resolve_title_from_book_data(book_data: Any) -> str: return "Unknown title" -def _normalize_optional_source_id(value: Any) -> str | None: +def _normalize_optional_source_id(value: object) -> str | None: """Normalize source identifiers while allowing integer provider ids.""" if isinstance(value, bool) or value is None: return None @@ -140,9 +146,9 @@ def _build_release_result_data_from_book_data( content_type: str, ) -> dict[str, Any]: """Build release-level payload fields for sources whose browse results are releases.""" - source_id = _normalize_optional_source_id(book_data.get("provider_id")) or _normalize_optional_source_id( - book_data.get("id") - ) + source_id = _normalize_optional_source_id( + book_data.get("provider_id") + ) or _normalize_optional_source_id(book_data.get("id")) payload: dict[str, Any] = { "source": source, "source_id": source_id, @@ -164,15 +170,16 @@ def _source_results_are_releases(source: str) -> bool: if normalized_source in {"", "*"}: return False from shelfmark.release_sources import source_results_are_releases + return source_results_are_releases(normalized_source) def _normalize_release_result_request_payload( *, source: str, - request_level: Any, - book_data: Any, - release_data: Any, + request_level: object, + book_data: object, + release_data: object, content_type: str, ) -> tuple[Any, Any]: """Concrete-release browse results are always handled as release-level requests.""" @@ -194,13 +201,15 @@ def _normalize_release_result_request_payload( if normalized_release_data.get("content_type") is None: normalized_release_data["content_type"] = content_type - normalized_source_id = _normalize_optional_source_id(normalized_release_data.get("source_id")) + normalized_source_id = _normalize_optional_source_id( + normalized_release_data.get("source_id") + ) if normalized_source_id is not None: normalized_release_data["source_id"] = normalized_source_id elif isinstance(book_data, dict): - fallback_source_id = _normalize_optional_source_id(book_data.get("provider_id")) or _normalize_optional_source_id( - book_data.get("id") - ) + fallback_source_id = _normalize_optional_source_id( + book_data.get("provider_id") + ) or _normalize_optional_source_id(book_data.get("id")) if fallback_source_id is not None: normalized_release_data["source_id"] = fallback_source_id @@ -237,26 +246,30 @@ def _resolve_request_user_context( *, actor_user_id: int, actor_username: str | None, - on_behalf_of_user_id: Any, + on_behalf_of_user_id: object, ) -> tuple[int, str | None, str]: if on_behalf_of_user_id in (None, ""): actor_label = _format_user_label(actor_username, actor_user_id) return actor_user_id, actor_username, actor_label if not session.get("is_admin", False): - raise RequestServiceError("Admin required", status_code=403) + msg = "Admin required" + raise RequestServiceError(msg, status_code=403) try: target_user_id = int(on_behalf_of_user_id) except (TypeError, ValueError) as exc: - raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400) from exc + msg = "Invalid on_behalf_of_user_id" + raise RequestServiceError(msg, status_code=400) from exc if target_user_id <= 0: - raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400) + msg = "Invalid on_behalf_of_user_id" + raise RequestServiceError(msg, status_code=400) target_user = user_db.get_user(user_id=target_user_id) if not target_user: - raise RequestServiceError("User not found", status_code=404) + msg = "User not found" + raise RequestServiceError(msg, status_code=404) target_username = normalize_optional_text(target_user.get("username")) actor_label = _format_user_label(actor_username, actor_user_id) @@ -270,8 +283,9 @@ def _prepare_request_create_arguments( ) -> dict[str, Any]: db_user_id, db_gate = _require_db_user_id() if db_gate is not None or db_user_id is None: + msg = "User identity is unavailable for request workflow" raise RequestServiceError( - "User identity is unavailable for request workflow", + msg, status_code=403, code="user_identity_unavailable", ) @@ -286,7 +300,8 @@ def _prepare_request_create_arguments( context = data.get("context") or {} if not isinstance(context, dict): - raise RequestServiceError("context must be an object", status_code=400) + msg = "context must be an object" + raise RequestServiceError(msg, status_code=400) source = normalize_source(context.get("source")) release_data = data.get("release_data") @@ -296,13 +311,12 @@ def _prepare_request_create_arguments( book_data = data.get("book_data") if not isinstance(book_data, dict): - raise RequestServiceError("book_data must be an object", status_code=400) + msg = "book_data must be an object" + raise RequestServiceError(msg, status_code=400) request_title = _resolve_title_from_book_data(book_data) content_type = normalize_content_type( - context.get("content_type") - or data.get("content_type") - or book_data.get("content_type") + context.get("content_type") or data.get("content_type") or book_data.get("content_type") ) request_level, release_data = _normalize_release_result_request_payload( source=source, @@ -317,8 +331,9 @@ def _prepare_request_create_arguments( db_user_id=target_user_id, ) if not requests_enabled: + msg = "Request workflow is disabled by policy" raise RequestServiceError( - "Request workflow is disabled by policy", + msg, status_code=403, code="requests_unavailable", ) @@ -327,10 +342,8 @@ def _prepare_request_create_arguments( effective.get("MAX_PENDING_REQUESTS_PER_USER"), default=20, ) - if max_pending < 1: - max_pending = 1 - if max_pending > 1000: - max_pending = 1000 + max_pending = max(max_pending, 1) + max_pending = min(max_pending, 1000) allow_notes = coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True) note_value = data.get("note") if allow_notes else None @@ -342,7 +355,7 @@ def _prepare_request_create_arguments( ) logger.debug( "request create policy actor=%s target_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s", - session.get("user_id"), + actor_label, target_user_id, source, content_type, @@ -351,8 +364,9 @@ def _prepare_request_create_arguments( ) if resolved_mode == PolicyMode.BLOCKED: + msg = "Requesting is blocked by policy" raise RequestServiceError( - "Requesting is blocked by policy", + msg, status_code=403, code="policy_blocked", required_mode=PolicyMode.BLOCKED.value, @@ -360,8 +374,9 @@ def _prepare_request_create_arguments( requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else "" if resolved_mode == PolicyMode.REQUEST_BOOK and requested_level != "book": + msg = "Policy requires book-level requests" raise RequestServiceError( - "Policy requires book-level requests", + msg, status_code=403, code="policy_requires_request", required_mode=PolicyMode.REQUEST_BOOK.value, @@ -385,7 +400,9 @@ def _prepare_request_create_arguments( } -def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str, str | None]: +def _resolve_request_source_and_format( + request_row: dict[str, Any], +) -> tuple[str, str | None]: release_data = request_row.get("release_data") if isinstance(release_data, dict): source = normalize_source(release_data.get("source") or request_row.get("source_hint")) @@ -431,8 +448,9 @@ def _queue_prepared_download_submission( ) -> dict[str, Any]: release_data = create_args.get("release_data") if not isinstance(release_data, dict): + msg = "Download policy requires a concrete release" raise RequestServiceError( - "Download policy requires a concrete release", + msg, status_code=400, code="policy_requires_download", required_mode=PolicyMode.DOWNLOAD.value, @@ -440,7 +458,8 @@ def _queue_prepared_download_submission( requester = user_db.get_user(user_id=create_args["user_id"]) if requester is None: - raise RequestServiceError("Requesting user not found", status_code=404) + msg = "Requesting user not found" + raise RequestServiceError(msg, status_code=404) success, error = queue_release( dict(release_data), @@ -461,8 +480,6 @@ def _queue_prepared_download_submission( ) - - def _notify_admin_for_request_event( user_db: UserDB, *, @@ -516,12 +533,12 @@ def register_request_routes( *, resolve_auth_mode: Callable[[], str], queue_release: Callable[..., tuple[bool, str | None]], - ws_manager: Any | None = None, + ws_manager: object | None = None, ) -> None: """Register request policy and request lifecycle routes.""" @app.route("/api/request-policy", methods=["GET"]) - def api_request_policy(): + def api_request_policy() -> Response | tuple[Response, int]: auth_gate = _require_request_endpoints_available(resolve_auth_mode) if auth_gate is not None: return auth_gate @@ -537,7 +554,7 @@ def register_request_routes( if raw_id is not None: try: db_user_id = int(raw_id) - except (TypeError, ValueError): + except TypeError, ValueError: db_user_id = None global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy( @@ -550,6 +567,7 @@ def register_request_routes( source_capabilities = get_source_content_type_capabilities() from shelfmark.release_sources import source_results_are_releases + source_modes = [] for source_name in sorted(source_capabilities): supported_types = sorted( @@ -597,7 +615,7 @@ def register_request_routes( ) @app.route("/api/requests", methods=["POST"]) - def api_create_request(): + def api_create_request() -> Response | tuple[Response, int]: auth_gate = _require_request_endpoints_available(resolve_auth_mode) if auth_gate is not None: return auth_gate @@ -663,7 +681,7 @@ def register_request_routes( return jsonify(created), 201 @app.route("/api/requests/batch", methods=["POST"]) - def api_create_requests_batch(): + def api_create_requests_batch() -> Response | tuple[Response, int]: auth_gate = _require_request_endpoints_available(resolve_auth_mode) if auth_gate is not None: return auth_gate @@ -716,7 +734,11 @@ def register_request_routes( results_by_index: dict[int, dict[str, Any]] = {} - for (index, prepared), created in zip(request_prepared_items, created_rows): + for (index, prepared), created in zip( + request_prepared_items, + created_rows, + strict=True, + ): event_payload = { "request_id": created["id"], "status": created["status"], @@ -773,7 +795,7 @@ def register_request_routes( return jsonify(ordered_results), status_code @app.route("/api/requests", methods=["GET"]) - def api_list_requests(): + def api_list_requests() -> Response | tuple[Response, int]: auth_gate = _require_request_endpoints_available(resolve_auth_mode) if auth_gate is not None: return auth_gate @@ -798,7 +820,7 @@ def register_request_routes( return jsonify(rows) @app.route("/api/requests/", methods=["DELETE"]) - def api_cancel_request(request_id: int): + def api_cancel_request(request_id: int) -> Response | tuple[Response, int]: auth_gate = _require_request_endpoints_available(resolve_auth_mode) if auth_gate is not None: return auth_gate @@ -821,7 +843,9 @@ def register_request_routes( "status": updated["status"], "title": _resolve_request_title(updated), } - actor_label = _format_user_label(normalize_optional_text(session.get("user_id")), db_user_id) + actor_label = _format_user_label( + normalize_optional_text(session.get("user_id")), db_user_id + ) logger.info( "Request cancelled #%s for '%s' by %s", updated["id"], @@ -844,7 +868,7 @@ def register_request_routes( return jsonify(updated) @app.route("/api/admin/requests", methods=["GET"]) - def api_admin_list_requests(): + def api_admin_list_requests() -> Response | tuple[Response, int]: auth_gate = _require_request_endpoints_available(resolve_auth_mode) if auth_gate is not None: return auth_gate @@ -865,17 +889,14 @@ def register_request_routes( return jsonify(rows) @app.route("/api/admin/requests/count", methods=["GET"]) - def api_admin_request_counts(): + def api_admin_request_counts() -> Response | tuple[Response, int]: auth_gate = _require_request_endpoints_available(resolve_auth_mode) if auth_gate is not None: return auth_gate if not session.get("is_admin", False): return jsonify({"error": "Admin access required"}), 403 - by_status = { - status: len(user_db.list_requests(status=status)) - for status in RequestStatus - } + by_status = {status: len(user_db.list_requests(status=status)) for status in RequestStatus} return jsonify( { "pending": by_status[RequestStatus.PENDING], @@ -885,7 +906,7 @@ def register_request_routes( ) @app.route("/api/admin/requests//fulfil", methods=["POST"]) - def api_admin_fulfil_request(request_id: int): + def api_admin_fulfil_request(request_id: int) -> Response | tuple[Response, int]: auth_gate = _require_request_endpoints_available(resolve_auth_mode) if auth_gate is not None: return auth_gate @@ -916,7 +937,9 @@ def register_request_routes( "status": updated["status"], "title": _resolve_request_title(updated), } - admin_label = _format_user_label(normalize_optional_text(session.get("user_id")), admin_user_id) + admin_label = _format_user_label( + normalize_optional_text(session.get("user_id")), admin_user_id + ) requester_label = _format_requester_label(user_db, updated) logger.info( "Request fulfilled #%s for '%s' by %s (requested by %s)", @@ -947,7 +970,7 @@ def register_request_routes( return jsonify(updated) @app.route("/api/admin/requests//reject", methods=["POST"]) - def api_admin_reject_request(request_id: int): + def api_admin_reject_request(request_id: int) -> Response | tuple[Response, int]: auth_gate = _require_request_endpoints_available(resolve_auth_mode) if auth_gate is not None: return auth_gate @@ -975,7 +998,9 @@ def register_request_routes( "status": updated["status"], "title": _resolve_request_title(updated), } - admin_label = _format_user_label(normalize_optional_text(session.get("user_id")), admin_user_id) + admin_label = _format_user_label( + normalize_optional_text(session.get("user_id")), admin_user_id + ) requester_label = _format_requester_label(user_db, updated) logger.info( "Request rejected #%s for '%s' by %s (requested by %s)", diff --git a/shelfmark/core/request_validation.py b/shelfmark/core/request_validation.py index af707e0c..628fe5aa 100644 --- a/shelfmark/core/request_validation.py +++ b/shelfmark/core/request_validation.py @@ -2,15 +2,15 @@ from __future__ import annotations -from enum import Enum -from typing import Any +from enum import StrEnum from shelfmark.core.models import QueueStatus from shelfmark.core.request_policy import parse_policy_mode -class RequestStatus(str, Enum): +class RequestStatus(StrEnum): """Enum for request lifecycle statuses.""" + PENDING = "pending" FULFILLED = "fulfilled" REJECTED = "rejected" @@ -20,65 +20,79 @@ class RequestStatus(str, Enum): DELIVERY_STATE_NONE = "none" VALID_REQUEST_STATUSES = frozenset(RequestStatus) -TERMINAL_REQUEST_STATUSES = frozenset({ - RequestStatus.FULFILLED, RequestStatus.REJECTED, RequestStatus.CANCELLED, -}) +TERMINAL_REQUEST_STATUSES = frozenset( + { + RequestStatus.FULFILLED, + RequestStatus.REJECTED, + RequestStatus.CANCELLED, + } +) VALID_REQUEST_LEVELS = frozenset({"book", "release"}) VALID_DELIVERY_STATES = frozenset({DELIVERY_STATE_NONE} | set(QueueStatus)) -def normalize_request_status(status: Any) -> str: +def normalize_request_status(status: object) -> str: """Validate and normalize request status values.""" if not isinstance(status, str): - raise ValueError(f"Invalid request status: {status}") + msg = f"Invalid request status: {status}" + raise TypeError(msg) normalized = status.strip().lower() if normalized not in VALID_REQUEST_STATUSES: - raise ValueError(f"Invalid request status: {status}") + msg = f"Invalid request status: {status}" + raise ValueError(msg) return normalized -def normalize_policy_mode(mode: Any) -> str: +def normalize_policy_mode(mode: object) -> str: """Validate and normalize policy mode values.""" parsed = parse_policy_mode(mode) if parsed is None: - raise ValueError(f"Invalid policy_mode: {mode}") + msg = f"Invalid policy_mode: {mode}" + raise ValueError(msg) return parsed.value -def normalize_request_level(request_level: Any) -> str: +def normalize_request_level(request_level: object) -> str: """Validate and normalize request level values.""" if not isinstance(request_level, str): - raise ValueError(f"Invalid request_level: {request_level}") + msg = f"Invalid request_level: {request_level}" + raise TypeError(msg) normalized = request_level.strip().lower() if normalized not in VALID_REQUEST_LEVELS: - raise ValueError(f"Invalid request_level: {request_level}") + msg = f"Invalid request_level: {request_level}" + raise ValueError(msg) return normalized -def normalize_delivery_state(state: Any) -> str: +def normalize_delivery_state(state: object) -> str: """Validate and normalize delivery-state values.""" if not isinstance(state, str): - raise ValueError(f"Invalid delivery_state: {state}") + msg = f"Invalid delivery_state: {state}" + raise TypeError(msg) normalized = state.strip().lower() if normalized not in VALID_DELIVERY_STATES: - raise ValueError(f"Invalid delivery_state: {state}") + msg = f"Invalid delivery_state: {state}" + raise ValueError(msg) return normalized -def validate_request_level_payload(request_level: Any, release_data: Any) -> str: +def validate_request_level_payload(request_level: object, release_data: object) -> str: """Validate request_level and release_data shape coupling.""" normalized_level = normalize_request_level(request_level) if normalized_level == "release" and release_data is None: - raise ValueError("request_level=release requires non-null release_data") + msg = "request_level=release requires non-null release_data" + raise ValueError(msg) if normalized_level == "book" and release_data is not None: - raise ValueError("request_level=book requires null release_data") + msg = "request_level=book requires null release_data" + raise ValueError(msg) return normalized_level -def validate_status_transition(current_status: Any, new_status: Any) -> tuple[str, str]: +def validate_status_transition(current_status: object, new_status: object) -> tuple[str, str]: """Validate request status transitions and terminal immutability.""" current = normalize_request_status(current_status) new = normalize_request_status(new_status) if current in TERMINAL_REQUEST_STATUSES and new != current: - raise ValueError("Terminal request statuses are immutable") + msg = "Terminal request statuses are immutable" + raise ValueError(msg) return current, new diff --git a/shelfmark/core/requests_service.py b/shelfmark/core/requests_service.py index 528dd314..7cd03c1e 100644 --- a/shelfmark/core/requests_service.py +++ b/shelfmark/core/requests_service.py @@ -2,29 +2,30 @@ from __future__ import annotations -from datetime import datetime, timezone import json -from typing import Any, Callable, TYPE_CHECKING +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any -from shelfmark.core.request_policy import normalize_content_type from shelfmark.core.models import QueueStatus +from shelfmark.core.request_helpers import ( + extract_release_source_id, + normalize_positive_int, +) +from shelfmark.core.request_policy import normalize_content_type from shelfmark.core.request_validation import ( DELIVERY_STATE_NONE, RequestStatus, normalize_policy_mode, - normalize_request_level, - normalize_request_status, validate_request_level_payload, - validate_status_transition, ) -from shelfmark.core.request_helpers import extract_release_source_id, normalize_positive_int - MAX_REQUEST_NOTE_LENGTH = 1000 MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024 if TYPE_CHECKING: + from collections.abc import Callable + from shelfmark.core.user_db import UserDB @@ -38,68 +39,76 @@ class RequestServiceError(ValueError): status_code: int = 400, code: str | None = None, required_mode: str | None = None, - ): + ) -> None: super().__init__(message) self.status_code = status_code self.code = code self.required_mode = required_mode -def _normalize_match_text(value: Any) -> str: +def _normalize_match_text(value: object) -> str: if not isinstance(value, str): return "" return value.strip().lower() -def normalize_note(note: Any) -> str | None: +def normalize_note(note: object) -> str | None: """Validate request notes and normalize empty strings to None.""" if note is None: return None if not isinstance(note, str): - raise RequestServiceError("note must be a string", status_code=400) + msg = "note must be a string" + raise RequestServiceError(msg, status_code=400) normalized = note.strip() if len(normalized) > MAX_REQUEST_NOTE_LENGTH: + msg_0 = f"note must be <= {MAX_REQUEST_NOTE_LENGTH} characters" raise RequestServiceError( - f"note must be <= {MAX_REQUEST_NOTE_LENGTH} characters", + msg_0, status_code=400, ) return normalized or None -def _validate_book_data(book_data: Any) -> dict[str, Any]: +def _validate_book_data(book_data: object) -> dict[str, Any]: if not isinstance(book_data, dict): - raise RequestServiceError("book_data must be an object", status_code=400) + msg = "book_data must be an object" + raise RequestServiceError(msg, status_code=400) required_fields = ("title", "author", "provider", "provider_id") - missing = [field for field in required_fields if not _normalize_match_text(book_data.get(field))] + missing = [ + field for field in required_fields if not _normalize_match_text(book_data.get(field)) + ] if missing: + msg_0 = f"book_data missing required field(s): {', '.join(missing)}" raise RequestServiceError( - f"book_data missing required field(s): {', '.join(missing)}", + msg_0, status_code=400, ) return dict(book_data) -def _validate_json_blob_size(field: str, payload: Any) -> None: +def _validate_json_blob_size(field: str, payload: object) -> None: if payload is None: return try: serialized = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) except (TypeError, ValueError) as exc: - raise RequestServiceError(f"{field} must be JSON-serializable", status_code=400) from exc + msg = f"{field} must be JSON-serializable" + raise RequestServiceError(msg, status_code=400) from exc payload_size = len(serialized.encode("utf-8")) if payload_size > MAX_REQUEST_JSON_BLOB_BYTES: + msg = f"{field} must be <= {MAX_REQUEST_JSON_BLOB_BYTES} bytes" raise RequestServiceError( - f"{field} must be <= {MAX_REQUEST_JSON_BLOB_BYTES} bytes", + msg, status_code=400, code="request_payload_too_large", ) def _find_duplicate_pending_request( - user_db: "UserDB", + user_db: UserDB, *, user_id: int, title: str, @@ -123,14 +132,15 @@ def _find_duplicate_pending_request( def _now_timestamp() -> str: - return datetime.now(timezone.utc).isoformat(timespec="seconds") + return datetime.now(UTC).isoformat(timespec="seconds") -def _normalize_admin_note(admin_note: Any) -> str | None: +def _normalize_admin_note(admin_note: object) -> str | None: if admin_note is None: return None if not isinstance(admin_note, str): - raise RequestServiceError("admin_note must be a string", status_code=400) + msg = "admin_note must be a string" + raise RequestServiceError(msg, status_code=400) return admin_note.strip() or None @@ -138,12 +148,12 @@ def _prepare_request_create( *, user_id: int, source_hint: str | None, - content_type: Any, - request_level: Any, - policy_mode: Any, - book_data: Any, - release_data: Any = None, - note: Any = None, + content_type: object, + request_level: object, + policy_mode: object, + book_data: object, + release_data: object = None, + note: object = None, ) -> dict[str, Any]: validated_book_data = _validate_book_data(book_data) normalized_note = normalize_note(note) @@ -155,7 +165,7 @@ def _prepare_request_create( try: normalized_request_level = validate_request_level_payload(request_level, release_data) normalized_policy_mode = normalize_policy_mode(policy_mode) - except ValueError as exc: + except (ValueError, TypeError) as exc: raise RequestServiceError(str(exc), status_code=400) from exc _validate_json_blob_size("book_data", validated_book_data) @@ -174,7 +184,7 @@ def _prepare_request_create( def sync_delivery_states_from_queue_status( - user_db: "UserDB", + user_db: UserDB, *, queue_status: dict[str, dict[str, Any]], user_id: int | None = None, @@ -257,16 +267,16 @@ def sync_delivery_states_from_queue_status( def create_request( - user_db: "UserDB", + user_db: UserDB, *, user_id: int, source_hint: str | None, - content_type: Any, - request_level: Any, - policy_mode: Any, - book_data: Any, - release_data: Any = None, - note: Any = None, + content_type: object, + request_level: object, + policy_mode: object, + book_data: object, + release_data: object = None, + note: object = None, max_pending_per_user: int | None = None, ) -> dict[str, Any]: """Create a pending request after service-level validation.""" @@ -284,8 +294,9 @@ def create_request( if max_pending_per_user is not None: pending_count = user_db.count_user_pending_requests(user_id) if pending_count >= max_pending_per_user: + msg = "Maximum pending requests reached for this user" raise RequestServiceError( - "Maximum pending requests reached for this user", + msg, status_code=409, code="max_pending_reached", ) @@ -298,26 +309,28 @@ def create_request( content_type=prepared_request["content_type"], ) if duplicate is not None: + msg = "Duplicate pending request exists for this title/author/content_type" raise RequestServiceError( - "Duplicate pending request exists for this title/author/content_type", + msg, status_code=409, code="duplicate_pending_request", ) try: return user_db.create_request(**prepared_request) - except ValueError as exc: + except (ValueError, TypeError) as exc: raise RequestServiceError(str(exc), status_code=400) from exc def create_requests( - user_db: "UserDB", + user_db: UserDB, *, requests: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Create multiple pending requests atomically after validation.""" if not isinstance(requests, list) or len(requests) == 0: - raise RequestServiceError("requests must contain at least one request", status_code=400) + msg = "requests must contain at least one request" + raise RequestServiceError(msg, status_code=400) prepared_requests: list[dict[str, Any]] = [] pending_counts_by_user: dict[int, int] = {} @@ -325,7 +338,8 @@ def create_requests( for request in requests: if not isinstance(request, dict): - raise RequestServiceError("requests must contain objects", status_code=400) + msg = "requests must contain objects" + raise RequestServiceError(msg, status_code=400) user_id = int(request["user_id"]) prepared_request = _prepare_request_create( @@ -346,8 +360,9 @@ def create_requests( prepared_request["content_type"], ) if request_key in seen_request_keys: + msg = "Duplicate pending request exists for this title/author/content_type" raise RequestServiceError( - "Duplicate pending request exists for this title/author/content_type", + msg, status_code=409, code="duplicate_pending_request", ) @@ -359,8 +374,9 @@ def create_requests( if existing_pending is None: existing_pending = user_db.count_user_pending_requests(user_id) if existing_pending >= max_pending_per_user: + msg = "Maximum pending requests reached for this user" raise RequestServiceError( - "Maximum pending requests reached for this user", + msg, status_code=409, code="max_pending_reached", ) @@ -374,8 +390,9 @@ def create_requests( content_type=request_key[3], ) if duplicate is not None: + msg = "Duplicate pending request exists for this title/author/content_type" raise RequestServiceError( - "Duplicate pending request exists for this title/author/content_type", + msg, status_code=409, code="duplicate_pending_request", ) @@ -389,7 +406,7 @@ def create_requests( def ensure_request_access( - user_db: "UserDB", + user_db: UserDB, *, request_id: int, actor_user_id: int | None, @@ -398,26 +415,28 @@ def ensure_request_access( """Get request by ID and enforce ownership for non-admin actors.""" request_row = user_db.get_request(request_id) if request_row is None: - raise RequestServiceError("Request not found", status_code=404) + msg = "Request not found" + raise RequestServiceError(msg, status_code=404) - if not is_admin: - if actor_user_id is None or request_row["user_id"] != actor_user_id: - raise RequestServiceError("Forbidden", status_code=403) + if not is_admin and (actor_user_id is None or request_row["user_id"] != actor_user_id): + msg = "Forbidden" + raise RequestServiceError(msg, status_code=403) return request_row def _require_pending(request_row: dict[str, Any]) -> None: if request_row["status"] != RequestStatus.PENDING: + msg = "Request is already in a terminal state" raise RequestServiceError( - "Request is already in a terminal state", + msg, status_code=409, code="stale_transition", ) def cancel_request( - user_db: "UserDB", + user_db: UserDB, *, request_id: int, actor_user_id: int, @@ -437,16 +456,18 @@ def cancel_request( expected_current_status=RequestStatus.PENDING, status=RequestStatus.CANCELLED, ) + except TypeError as exc: + raise RequestServiceError(str(exc), status_code=400) from exc except ValueError as exc: raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc def reject_request( - user_db: "UserDB", + user_db: UserDB, *, request_id: int, admin_user_id: int, - admin_note: Any = None, + admin_note: object = None, ) -> dict[str, Any]: """Reject a pending request as admin.""" request_row = ensure_request_access( @@ -468,19 +489,21 @@ def reject_request( reviewed_by=admin_user_id, reviewed_at=_now_timestamp(), ) + except TypeError as exc: + raise RequestServiceError(str(exc), status_code=400) from exc except ValueError as exc: raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc def fulfil_request( - user_db: "UserDB", + user_db: UserDB, *, request_id: int, admin_user_id: int, queue_release: Callable[..., tuple[bool, str | None]], - release_data: Any = None, - admin_note: Any = None, - manual_approval: Any = False, + release_data: object = None, + admin_note: object = None, + manual_approval: object = False, ) -> dict[str, Any]: """Fulfil a pending request and queue the release under requesting-user identity.""" request_row = ensure_request_access( @@ -494,11 +517,15 @@ def fulfil_request( normalized_admin_note = _normalize_admin_note(admin_note) if not isinstance(manual_approval, bool): - raise RequestServiceError("manual_approval must be a boolean", status_code=400) + msg = "manual_approval must be a boolean" + raise RequestServiceError(msg, status_code=400) - selected_release_data = release_data if release_data is not None else request_row.get("release_data") + selected_release_data = ( + release_data if release_data is not None else request_row.get("release_data") + ) if selected_release_data is not None and not isinstance(selected_release_data, dict): - raise RequestServiceError("release_data must be an object", status_code=400) + msg = "release_data must be an object" + raise RequestServiceError(msg, status_code=400) if selected_release_data is None and manual_approval: try: @@ -514,12 +541,15 @@ def fulfil_request( reviewed_by=admin_user_id, reviewed_at=_now_timestamp(), ) + except TypeError as exc: + raise RequestServiceError(str(exc), status_code=400) from exc except ValueError as exc: raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc if selected_release_data is None: + msg = "release_data is required to fulfil requests" raise RequestServiceError( - "release_data is required to fulfil requests", + msg, status_code=400, ) @@ -527,7 +557,8 @@ def fulfil_request( requester = user_db.get_user(user_id=request_row["user_id"]) if requester is None: - raise RequestServiceError("Requesting user not found", status_code=404) + msg = "Requesting user not found" + raise RequestServiceError(msg, status_code=404) original_release_data = request_row.get("release_data") try: @@ -543,6 +574,8 @@ def fulfil_request( reviewed_by=admin_user_id, reviewed_at=_now_timestamp(), ) + except TypeError as exc: + raise RequestServiceError(str(exc), status_code=400) from exc except ValueError as exc: raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc @@ -579,7 +612,7 @@ def fulfil_request( def reopen_failed_request( - user_db: "UserDB", + user_db: UserDB, *, request_id: int, failure_reason: str | None = None, diff --git a/shelfmark/core/search_plan.py b/shelfmark/core/search_plan.py index 415ef8f5..80de75b8 100644 --- a/shelfmark/core/search_plan.py +++ b/shelfmark/core/search_plan.py @@ -1,18 +1,20 @@ from __future__ import annotations from dataclasses import dataclass -from typing import List, Optional +from typing import TYPE_CHECKING MANUAL_QUERY_MAX_LEN = 256 from shelfmark.core.config import config -from shelfmark.core.models import SearchFilters from shelfmark.metadata_providers import ( BookMetadata, - group_languages_by_localized_title, build_localized_search_titles, + group_languages_by_localized_title, ) +if TYPE_CHECKING: + from shelfmark.core.models import SearchFilters + @dataclass(frozen=True) class ReleaseSearchVariant: @@ -20,7 +22,7 @@ class ReleaseSearchVariant: title: str author: str - languages: Optional[List[str]] = None + languages: list[str] | None = None @property def query(self) -> str: @@ -31,28 +33,28 @@ class ReleaseSearchVariant: class ReleaseSearchPlan: """Pre-computed search inputs shared across release sources.""" - languages: Optional[List[str]] - isbn_candidates: List[str] + languages: list[str] | None + isbn_candidates: list[str] author: str - title_variants: List[ReleaseSearchVariant] - grouped_title_variants: List[ReleaseSearchVariant] - manual_query: Optional[str] = None - indexers: Optional[List[str]] = None # Indexer names for Prowlarr (overrides settings) - source_filters: Optional[SearchFilters] = None + title_variants: list[ReleaseSearchVariant] + grouped_title_variants: list[ReleaseSearchVariant] + manual_query: str | None = None + indexers: list[str] | None = None # Indexer names for Prowlarr (overrides settings) + source_filters: SearchFilters | None = None @property def primary_query(self) -> str: return self.title_variants[0].query if self.title_variants else "" -def _normalize_languages(languages: Optional[List[str]]) -> Optional[List[str]]: +def _normalize_languages(languages: list[str] | None) -> list[str] | None: if not languages: default = config.BOOK_LANGUAGE if not default: return None return [str(lang).strip() for lang in default if str(lang).strip()] - normalized: List[str] = [] + normalized: list[str] = [] for lang in languages: if not lang: continue @@ -87,10 +89,10 @@ def _pick_search_title(book: BookMetadata) -> str: def build_release_search_plan( book: BookMetadata, - languages: Optional[List[str]] = None, - manual_query: Optional[str] = None, - indexers: Optional[List[str]] = None, - source_filters: Optional[SearchFilters] = None, + languages: list[str] | None = None, + manual_query: str | None = None, + indexers: list[str] | None = None, + source_filters: SearchFilters | None = None, ) -> ReleaseSearchPlan: resolved_languages = _normalize_languages(languages) @@ -115,7 +117,7 @@ def build_release_search_plan( source_filters=source_filters, ) - isbn_candidates: List[str] = [] + isbn_candidates: list[str] = [] if book.isbn_13: isbn_candidates.append(book.isbn_13) if book.isbn_10 and book.isbn_10 not in isbn_candidates: @@ -135,7 +137,7 @@ def build_release_search_plan( titles_by_language=titles_by_language, ) - grouped_variants: List[ReleaseSearchVariant] = [ + grouped_variants: list[ReleaseSearchVariant] = [ ReleaseSearchVariant(title=title, author=author, languages=langs) for title, langs in grouped if title @@ -148,7 +150,7 @@ def build_release_search_plan( excluded_languages={"en", "eng", "english"}, ) - title_variants: List[ReleaseSearchVariant] = [ + title_variants: list[ReleaseSearchVariant] = [ ReleaseSearchVariant(title=title, author=author, languages=None) for title in expanded_titles if title @@ -157,8 +159,7 @@ def build_release_search_plan( # If no titles could be built, fall back to ISBN queries. if not title_variants and isbn_candidates: title_variants = [ - ReleaseSearchVariant(title=isbn, author="", languages=None) - for isbn in isbn_candidates + ReleaseSearchVariant(title=isbn, author="", languages=None) for isbn in isbn_candidates ] return ReleaseSearchPlan( diff --git a/shelfmark/core/self_user_routes.py b/shelfmark/core/self_user_routes.py index 650c2350..7263360a 100644 --- a/shelfmark/core/self_user_routes.py +++ b/shelfmark/core/self_user_routes.py @@ -1,9 +1,9 @@ """Self-service user account routes.""" from functools import wraps -from typing import Any, Callable, Mapping +from typing import TYPE_CHECKING, Any -from flask import Flask, g, jsonify, request, session +from flask import Flask, Response, g, jsonify, request, session from werkzeug.security import generate_password_hash from shelfmark.config.env import CWA_DB_PATH @@ -24,9 +24,15 @@ from shelfmark.core.logger import setup_logger from shelfmark.core.settings_registry import load_config_file from shelfmark.core.user_settings_overrides import ( build_user_preferences_payload as _build_user_preferences_payload, +) +from shelfmark.core.user_settings_overrides import ( get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields, ) -from shelfmark.core.user_db import UserDB + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from shelfmark.core.user_db import UserDB logger = setup_logger(__name__) @@ -43,11 +49,13 @@ _VALID_SELF_SETTINGS_SECTIONS = ( _DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS = list(_VALID_SELF_SETTINGS_SECTIONS) -def _get_current_user(user_db: UserDB) -> tuple[int | None, dict[str, Any] | None, tuple[Any, int] | None]: +def _get_current_user( + user_db: UserDB, +) -> tuple[int | None, dict[str, Any] | None, tuple[Any, int] | None]: raw_user_id = session.get("db_user_id") try: user_id = int(raw_user_id) - except (TypeError, ValueError): + except TypeError, ValueError: return None, None, (jsonify({"error": "Invalid user context"}), 400) user = user_db.get_user(user_id=user_id) @@ -83,7 +91,7 @@ def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, A return payload -def _normalize_visible_self_settings_sections(raw_sections: Any) -> list[str]: +def _normalize_visible_self_settings_sections(raw_sections: object) -> list[str]: """Normalize users.VISIBLE_SELF_SETTINGS_SECTIONS to a safe ordered list.""" if raw_sections is None: return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS) @@ -91,7 +99,9 @@ def _normalize_visible_self_settings_sections(raw_sections: Any) -> list[str]: if isinstance(raw_sections, str): candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()] elif isinstance(raw_sections, (list, tuple, set)): - candidate_sections = [str(section).strip() for section in raw_sections if str(section).strip()] + candidate_sections = [ + str(section).strip() for section in raw_sections if str(section).strip() + ] else: return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS) @@ -118,14 +128,10 @@ def _get_allowed_self_settings_keys(visible_sections: list[str]) -> set[str]: visible_sections_set = set(visible_sections) if _SELF_SETTINGS_SECTION_DELIVERY in visible_sections_set: - allowed_keys |= { - key for key, _field in _get_ordered_user_overridable_fields("downloads") - } + allowed_keys |= {key for key, _field in _get_ordered_user_overridable_fields("downloads")} if _SELF_SETTINGS_SECTION_SEARCH in visible_sections_set: - allowed_keys |= { - key for key, _field in _get_ordered_user_overridable_fields("search_mode") - } + allowed_keys |= {key for key, _field in _get_ordered_user_overridable_fields("search_mode")} if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_sections_set: allowed_keys |= { @@ -138,25 +144,31 @@ def _get_allowed_self_settings_keys(visible_sections: list[str]) -> set[str]: def register_self_user_routes(app: Flask, user_db: UserDB) -> None: """Register self-service user endpoints.""" - def _require_authenticated_user(f: Callable[..., Any]) -> Callable[..., Any]: + def _require_authenticated_user( + f: Callable[..., Response | tuple[Response, int]], + ) -> Callable[..., Response | tuple[Response, int]]: """Decorator requiring an authenticated session linked to a local user row. Caches the resolved auth_mode in ``g.auth_mode`` for the request. """ + @wraps(f) - def decorated(*args, **kwargs): + def decorated(*args, **kwargs) -> Response | tuple[Response, int]: auth_mode = load_active_auth_mode(CWA_DB_PATH, user_db=user_db) g.auth_mode = auth_mode if auth_mode != "none" and "user_id" not in session: return jsonify({"error": "Authentication required"}), 401 if "db_user_id" not in session: - return jsonify({"error": "Authenticated session is missing local user context"}), 403 + return jsonify( + {"error": "Authenticated session is missing local user context"} + ), 403 return f(*args, **kwargs) + return decorated @app.route("/api/users/me/edit-context", methods=["GET"]) @_require_authenticated_user - def users_me_edit_context(): + def users_me_edit_context() -> Response | tuple[Response, int]: user_id, user, user_error = _get_current_user(user_db) if user_error: return user_error @@ -168,31 +180,49 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: delivery_preferences = None if _SELF_SETTINGS_SECTION_DELIVERY in visible_self_settings_sections: try: - delivery_preferences = _build_user_preferences_payload(user_db, user_id, "downloads") + delivery_preferences = _build_user_preferences_payload( + user_db, user_id, "downloads" + ) except ValueError: return jsonify({"error": "Downloads settings tab not found"}), 500 except Exception as exc: - logger.warning(f"Failed to build user delivery preferences for user_id={user_id}: {exc}") + logger.warning( + "Failed to build user delivery preferences for user_id=%s: %s", + user_id, + exc, + ) delivery_preferences = None search_preferences = None if _SELF_SETTINGS_SECTION_SEARCH in visible_self_settings_sections: try: - search_preferences = _build_user_preferences_payload(user_db, user_id, "search_mode") + search_preferences = _build_user_preferences_payload( + user_db, user_id, "search_mode" + ) except ValueError: return jsonify({"error": "Search mode settings tab not found"}), 500 except Exception as exc: - logger.warning(f"Failed to build user search preferences for user_id={user_id}: {exc}") + logger.warning( + "Failed to build user search preferences for user_id=%s: %s", + user_id, + exc, + ) search_preferences = None notification_preferences = None if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_self_settings_sections: try: - notification_preferences = _build_user_preferences_payload(user_db, user_id, "notifications") + notification_preferences = _build_user_preferences_payload( + user_db, user_id, "notifications" + ) except ValueError: return jsonify({"error": "Notifications settings tab not found"}), 500 except Exception as exc: - logger.warning(f"Failed to build user notification preferences for user_id={user_id}: {exc}") + logger.warning( + "Failed to build user notification preferences for user_id=%s: %s", + user_id, + exc, + ) notification_preferences = None user_overridable_keys = sorted( @@ -214,7 +244,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: @app.route("/api/users/me/notification-preferences/test", methods=["POST"]) @_require_authenticated_user - def users_me_test_notification_preferences(): + def users_me_test_notification_preferences() -> Response | tuple[Response, int]: user_id, _user, user_error = _get_current_user(user_db) if user_error: return user_error @@ -230,7 +260,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: @app.route("/api/users/me", methods=["PUT"]) @_require_authenticated_user - def users_me_update(): + def users_me_update() -> Response | tuple[Response, int]: user_id, user, user_error = _get_current_user(user_db) if user_error: return user_error @@ -252,7 +282,9 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: } ), 400 if len(password) < MIN_PASSWORD_LENGTH: - return jsonify({"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}), 400 + return jsonify( + {"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"} + ), 400 user_db.update_user(user_id, password_hash=generate_password_hash(password)) user_fields: dict[str, Any] = {} @@ -271,10 +303,9 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: ) email_changed = "email" in user_fields and user_fields["email"] != user.get("email") - display_name_changed = ( - "display_name" in user_fields - and user_fields["display_name"] != user.get("display_name") - ) + display_name_changed = "display_name" in user_fields and user_fields[ + "display_name" + ] != user.get("display_name") if email_changed and not capabilities["canEditEmail"]: if auth_source == AUTH_SOURCE_CWA: @@ -312,7 +343,9 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: return jsonify({"error": "Settings must be an object"}), 400 visible_self_settings_sections = _get_visible_self_settings_sections() - allowed_user_settings_keys = _get_allowed_self_settings_keys(visible_self_settings_sections) + allowed_user_settings_keys = _get_allowed_self_settings_keys( + visible_self_settings_sections + ) disallowed_keys = sorted( key for key in settings_payload if key not in allowed_user_settings_keys ) @@ -349,5 +382,5 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: result = _serialize_self_user(updated, g.auth_mode) result["settings"] = user_db.get_user_settings(user_id) - logger.info(f"User {user_id} updated their own account") + logger.info("User %s updated their own account", user_id) return jsonify(result) diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py index 6d85c3a6..6c8e765a 100644 --- a/shelfmark/core/settings_registry.py +++ b/shelfmark/core/settings_registry.py @@ -2,34 +2,46 @@ import json import os -from dataclasses import dataclass, field, asdict +from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Type, Union from threading import Lock +from typing import TYPE_CHECKING, Any + +from werkzeug.utils import secure_filename from shelfmark.core.logger import setup_logger logger = setup_logger(__name__) +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + from shelfmark.core.config import Config + @dataclass class FieldBase: """Base class for all settings fields.""" - key: str # Environment variable / config key - label: str # Display label in UI - description: str = "" # Help text - default: Any = None # Default value if not set - required: bool = False # Whether field must have a value - env_var: Optional[str] = None # Override env var name (defaults to key) - env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only) - user_overridable: bool = False # Whether admins can set per-user overrides for this field - disabled: bool = False # Whether field is disabled/greyed out - disabled_reason: str = "" # Explanation shown when disabled - show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions - disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."} - requires_restart: bool = False # Whether changing this setting requires a container restart - universal_only: bool = False # Only show in Universal search mode (hide in Direct mode) - hidden_in_ui: bool = False # Keep field in schema/save path but hide default renderer + + key: str # Environment variable / config key + label: str # Display label in UI + description: str = "" # Help text + default: object = None # Default value if not set + required: bool = False # Whether field must have a value + env_var: str | None = None # Override env var name (defaults to key) + env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only) + user_overridable: bool = False # Whether admins can set per-user overrides for this field + disabled: bool = False # Whether field is disabled/greyed out + disabled_reason: str = "" # Explanation shown when disabled + show_when: dict[str, Any] | list[dict[str, Any]] | None = ( + None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions + ) + disabled_when: dict[str, Any] | None = ( + None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."} + ) + requires_restart: bool = False # Whether changing this setting requires a container restart + universal_only: bool = False # Only show in Universal search mode (hide in Direct mode) + hidden_in_ui: bool = False # Keep field in schema/save path but hide default renderer def get_env_var_name(self) -> str: """Get the environment variable name for this field.""" @@ -43,21 +55,24 @@ class FieldBase: @dataclass class TextField(FieldBase): """Single-line text input.""" + placeholder: str = "" - max_length: Optional[int] = None + max_length: int | None = None @dataclass class PasswordField(FieldBase): """Password input (masked in UI, not returned in API responses).""" + placeholder: str = "" @dataclass class NumberField(FieldBase): """Numeric input.""" - min_value: Optional[float] = None - max_value: Optional[float] = None + + min_value: float | None = None + max_value: float | None = None step: float = 1 default: float = 0 @@ -65,31 +80,35 @@ class NumberField(FieldBase): @dataclass class CheckboxField(FieldBase): """Boolean checkbox.""" + default: bool = False @dataclass class SelectField(FieldBase): """Single-choice dropdown.""" + # Options can be a list or a callable that returns a list (for lazy evaluation) - options: Any = field(default_factory=list) # [{value: "", label: ""}] or callable - filter_by_field: Optional[str] = None # Field key whose value filters options via childOf property + options: object = field(default_factory=list) # [{value: "", label: ""}] or callable + filter_by_field: str | None = None # Field key whose value filters options via childOf property @dataclass class MultiSelectField(FieldBase): """Multiple-choice selection.""" + # Options can be a list or a callable that returns a list (for lazy evaluation) - options: Any = field(default_factory=list) # [{value: "", label: ""}] or callable - default: List[str] = field(default_factory=list) + options: object = field(default_factory=list) # [{value: "", label: ""}] or callable + default: list[str] = field(default_factory=list) variant: str = "pills" # "pills" (default) or "dropdown" for checkbox dropdown style @dataclass class TagListField(FieldBase): """Editable list of free-form string values (tag/chip input).""" + placeholder: str = "" - default: List[str] = field(default_factory=list) + default: list[str] = field(default_factory=list) normalize_urls: bool = True @@ -99,9 +118,9 @@ class OrderableListField(FieldBase): # Each option: {id, label, description?, disabledReason?, isLocked?, section?, isPinned?} # - isLocked: toggle is disabled (can't enable/disable) # - isPinned: can't be reordered (but toggle may still work if not also isLocked) - options: Any = field(default_factory=list) + options: object = field(default_factory=list) # Default value: [{id, enabled}, ...] in priority order - default: List[Dict[str, Any]] = field(default_factory=list) + default: list[dict[str, Any]] = field(default_factory=list) @dataclass @@ -109,10 +128,10 @@ class TableField(FieldBase): """Editable table of structured rows.""" # Column definitions: [{key, label, type, placeholder?, options?, defaultValue?}, ...] - columns: Any = field(default_factory=list) # list or callable + columns: object = field(default_factory=list) # list or callable # Value format: list of objects - default: List[Dict[str, Any]] = field(default_factory=list) + default: list[dict[str, Any]] = field(default_factory=list) add_label: str = "Add" empty_message: str = "" @@ -123,37 +142,43 @@ class CustomComponentField: """Render a custom frontend component inside settings content.""" key: str - component: str # Frontend component registry key + component: str # Frontend component registry key label: str = "" description: str = "" - bind_keys: List[str] = field(default_factory=list) # Related value keys this component edits - value_fields: List[Any] = field(default_factory=list) # Backing value schema for this component - wrap_in_field_wrapper: bool = False # Whether to render with standard FieldWrapper layout + bind_keys: list[str] = field(default_factory=list) # Related value keys this component edits + value_fields: list[Any] = field(default_factory=list) # Backing value schema for this component + wrap_in_field_wrapper: bool = False # Whether to render with standard FieldWrapper layout disabled: bool = False disabled_reason: str = "" - show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None + show_when: dict[str, Any] | list[dict[str, Any]] | None = None universal_only: bool = False def get_field_type(self) -> str: return "CustomComponentField" - def get_bind_keys(self) -> List[str]: + def get_bind_keys(self) -> list[str]: if self.bind_keys: return self.bind_keys - return [getattr(f, "key") for f in self.value_fields if getattr(f, "key", None)] + return [f.key for f in self.value_fields if getattr(f, "key", None)] @dataclass class ActionButton: - key: str # Action identifier - label: str # Button text - description: str = "" # Help text - style: str = "default" # "default", "primary", "danger" - callback: Optional[Callable[..., Dict[str, Any]]] = None # Returns {"success": bool, "message": str} - disabled: bool = False # Whether button is disabled/greyed out - disabled_reason: str = "" # Explanation shown when disabled - show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions - disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."} + key: str # Action identifier + label: str # Button text + description: str = "" # Help text + style: str = "default" # "default", "primary", "danger" + callback: Callable[..., dict[str, Any]] | None = ( + None # Returns {"success": bool, "message": str} + ) + disabled: bool = False # Whether button is disabled/greyed out + disabled_reason: str = "" # Explanation shown when disabled + show_when: dict[str, Any] | list[dict[str, Any]] | None = ( + None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions + ) + disabled_when: dict[str, Any] | None = ( + None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."} + ) def get_field_type(self) -> str: return "ActionButton" @@ -161,74 +186,75 @@ class ActionButton: @dataclass class HeadingField: - """ - Display-only heading with title and description. + """Display-only heading with title and description. Used to add section titles and descriptive text to settings pages. Not an input field - purely for display. """ - key: str # Unique identifier - title: str # Heading title - description: str = "" # Description text (supports markdown-style links) - description_by_auth_mode: Optional[Dict[str, str]] = None # Optional auth-mode specific description map - link_url: str = "" # Optional URL for a link - link_text: str = "" # Text for the link (defaults to URL if not provided) - show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions - universal_only: bool = False # Only show in Universal search mode (hide in Direct mode) + + key: str # Unique identifier + title: str # Heading title + description: str = "" # Description text (supports markdown-style links) + description_by_auth_mode: dict[str, str] | None = ( + None # Optional auth-mode specific description map + ) + link_url: str = "" # Optional URL for a link + link_text: str = "" # Text for the link (defaults to URL if not provided) + show_when: dict[str, Any] | list[dict[str, Any]] | None = ( + None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions + ) + universal_only: bool = False # Only show in Universal search mode (hide in Direct mode) def get_field_type(self) -> str: return "HeadingField" # Type alias for all field types -SettingsField = Union[ - TextField, - PasswordField, - NumberField, - CheckboxField, - SelectField, - MultiSelectField, - TagListField, - OrderableListField, - TableField, - CustomComponentField, - ActionButton, - HeadingField, -] +SettingsField = ( + TextField + | PasswordField + | NumberField + | CheckboxField + | SelectField + | MultiSelectField + | TagListField + | OrderableListField + | TableField + | CustomComponentField + | ActionButton + | HeadingField +) @dataclass class SettingsTab: """A tab/section in the settings UI.""" - name: str # Internal name (used in URLs) - display_name: str # Display name in UI - fields: List[SettingsField] = field(default_factory=list) - icon: Optional[str] = None # Icon name for UI - order: int = 100 # Sort order (lower = earlier) - group: Optional[str] = None # Group name this tab belongs to + + name: str # Internal name (used in URLs) + display_name: str # Display name in UI + fields: list[SettingsField] = field(default_factory=list) + icon: str | None = None # Icon name for UI + order: int = 100 # Sort order (lower = earlier) + group: str | None = None # Group name this tab belongs to @dataclass class SettingsGroup: """A collapsible group of settings tabs in the UI.""" - name: str # Internal name - display_name: str # Display name in UI - icon: Optional[str] = None # Icon name for UI - order: int = 100 # Sort order (lower = earlier) + + name: str # Internal name + display_name: str # Display name in UI + icon: str | None = None # Icon name for UI + order: int = 100 # Sort order (lower = earlier) -_SETTINGS_REGISTRY: Dict[str, SettingsTab] = {} -_GROUPS_REGISTRY: Dict[str, SettingsGroup] = {} -_ON_SAVE_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {} +_SETTINGS_REGISTRY: dict[str, SettingsTab] = {} +_GROUPS_REGISTRY: dict[str, SettingsGroup] = {} +_ON_SAVE_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {} _REGISTRY_LOCK = Lock() -def register_group( - name: str, - display_name: str, - icon: Optional[str] = None, - order: int = 100 -) -> None: +def register_group(name: str, display_name: str, icon: str | None = None, order: int = 100) -> None: with _REGISTRY_LOCK: group = SettingsGroup( name=name, @@ -237,17 +263,17 @@ def register_group( order=order, ) _GROUPS_REGISTRY[name] = group - logger.debug(f"Registered settings group: {name}") + logger.debug("Registered settings group: %s", name) def register_settings( name: str, display_name: str, - icon: Optional[str] = None, + icon: str | None = None, order: int = 100, - group: Optional[str] = None -): - def decorator(func: Callable[[], List[SettingsField]]): + group: str | None = None, +) -> Callable[[Callable[[], list[SettingsField]]], Callable[[], list[SettingsField]]]: + def decorator(func: Callable[[], list[SettingsField]]) -> Callable[[], list[SettingsField]]: with _REGISTRY_LOCK: fields = func() tab = SettingsTab( @@ -259,53 +285,59 @@ def register_settings( group=group, ) _SETTINGS_REGISTRY[name] = tab - logger.debug(f"Registered settings tab: {name} ({len(fields)} fields)" + - (f" in group {group}" if group else "")) + logger.debug( + "Registered settings tab: %s (%s fields)%s", + name, + len(fields), + f" in group {group}" if group else "", + ) return func + return decorator -def register_on_save( - tab_name: str, - handler: Callable[[Dict[str, Any]], Dict[str, Any]] -) -> None: +def register_on_save(tab_name: str, handler: Callable[[dict[str, Any]], dict[str, Any]]) -> None: with _REGISTRY_LOCK: _ON_SAVE_HANDLERS[tab_name] = handler - logger.debug(f"Registered on_save handler for tab: {tab_name}") + logger.debug("Registered on_save handler for tab: %s", tab_name) -def get_on_save_handler(tab_name: str) -> Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]: +def get_on_save_handler( + tab_name: str, +) -> Callable[[dict[str, Any]], dict[str, Any]] | None: """Get the on_save handler for a settings tab, if any.""" return _ON_SAVE_HANDLERS.get(tab_name) -def get_settings_tab(name: str) -> Optional[SettingsTab]: +def get_settings_tab(name: str) -> SettingsTab | None: """Get a specific settings tab by name.""" return _SETTINGS_REGISTRY.get(name) -def get_all_settings_tabs() -> List[SettingsTab]: +def get_all_settings_tabs() -> list[SettingsTab]: """Get all registered settings tabs, sorted by order.""" return sorted(_SETTINGS_REGISTRY.values(), key=lambda t: (t.order, t.name)) -def _iter_value_fields(tab: SettingsTab): +def _iter_value_fields(tab: SettingsTab) -> Iterator[SettingsField]: """Yield value-bearing fields for a tab.""" - for field in tab.fields: - if isinstance(field, CustomComponentField): - for value_field in field.value_fields: + for settings_field in tab.fields: + if isinstance(settings_field, CustomComponentField): + for value_field in settings_field.value_fields: if isinstance(value_field, (ActionButton, HeadingField, CustomComponentField)): continue yield value_field continue - if isinstance(field, (ActionButton, HeadingField)): + if isinstance(settings_field, (ActionButton, HeadingField)): continue - yield field + yield settings_field -def get_settings_field_map(tab_name: Optional[str] = None) -> Dict[str, tuple[SettingsField, str]]: +def get_settings_field_map( + tab_name: str | None = None, +) -> dict[str, tuple[SettingsField, str]]: """Return key -> (field, tab_name) map for value-bearing settings fields.""" - tabs: List[SettingsTab] + tabs: list[SettingsTab] if tab_name: tab = get_settings_tab(tab_name) if not tab: @@ -314,14 +346,16 @@ def get_settings_field_map(tab_name: Optional[str] = None) -> Dict[str, tuple[Se else: tabs = get_all_settings_tabs() - field_map: Dict[str, tuple[SettingsField, str]] = {} + field_map: dict[str, tuple[SettingsField, str]] = {} for tab in tabs: - for field in _iter_value_fields(tab): - field_map[field.key] = (field, tab.name) + for settings_field in _iter_value_fields(tab): + field_map[settings_field.key] = (settings_field, tab.name) return field_map -def get_user_overridable_fields(tab_name: Optional[str] = None) -> Dict[str, tuple[SettingsField, str]]: +def get_user_overridable_fields( + tab_name: str | None = None, +) -> dict[str, tuple[SettingsField, str]]: """Return key -> (field, tab_name) map for fields marked user_overridable.""" field_map = get_settings_field_map(tab_name=tab_name) return { @@ -331,7 +365,7 @@ def get_user_overridable_fields(tab_name: Optional[str] = None) -> Dict[str, tup } -def list_registered_settings() -> List[str]: +def list_registered_settings() -> list[str]: """List all registered settings tab names.""" return list(_SETTINGS_REGISTRY.keys()) @@ -339,6 +373,7 @@ def list_registered_settings() -> List[str]: def _get_config_dir() -> Path: """Get the config directory path.""" from shelfmark.config.env import CONFIG_DIR + return Path(CONFIG_DIR) @@ -348,11 +383,21 @@ def _get_config_file_path(tab_name: str) -> Path: # Core settings tabs share the main settings.json file if tab_name in ("general", "search_mode"): return config_dir / "settings.json" - # Sanitize tab_name to prevent path traversal - safe_name = Path(tab_name).name + + # Plugin config file names should match their tab names exactly after + # filename sanitization, so request input cannot escape the plugins folder. + safe_name = secure_filename(tab_name) if not safe_name or safe_name != tab_name: - raise ValueError(f"Invalid tab name: {tab_name}") - return config_dir / "plugins" / f"{safe_name}.json" + msg = f"Invalid tab name: {tab_name}" + raise ValueError(msg) + + plugins_dir = (config_dir / "plugins").resolve(strict=False) + config_path = (plugins_dir / f"{safe_name}.json").resolve(strict=False) + if not config_path.is_relative_to(plugins_dir): + msg = f"Invalid tab name: {tab_name}" + raise ValueError(msg) + + return config_path def _ensure_config_dir(tab_name: str) -> None: @@ -361,21 +406,21 @@ def _ensure_config_dir(tab_name: str) -> None: config_path.parent.mkdir(parents=True, exist_ok=True) -def load_config_file(tab_name: str) -> Dict[str, Any]: +def load_config_file(tab_name: str) -> dict[str, Any]: config_path = _get_config_file_path(tab_name) if not config_path.exists(): return {} try: - with open(config_path, 'r') as f: + with config_path.open() as f: return json.load(f) - except json.JSONDecodeError as e: - logger.error(f"Invalid JSON in config file {config_path}: {e}") + except json.JSONDecodeError: + logger.exception("Invalid JSON in config file %s", config_path) return {} -def save_config_file(tab_name: str, values: Dict[str, Any]) -> bool: +def save_config_file(tab_name: str, values: dict[str, Any]) -> bool: try: _ensure_config_dir(tab_name) config_path = _get_config_file_path(tab_name) @@ -384,14 +429,15 @@ def save_config_file(tab_name: str, values: Dict[str, Any]) -> bool: existing = load_config_file(tab_name) existing.update(values) - with open(config_path, 'w') as f: + with config_path.open("w") as f: json.dump(existing, f, indent=2) - logger.info(f"Saved settings to {config_path}") - return True - except Exception as e: - logger.error(f"Error saving config file for {tab_name}: {e}") + logger.info("Saved settings to %s", config_path) + except Exception: + logger.exception("Error saving config file for %s", tab_name) return False + else: + return True def initialize_default_configs() -> bool: @@ -404,13 +450,14 @@ def initialize_default_configs() -> bool: Returns: True if initialization succeeded or was skipped (already initialized), False if there was an error accessing the config directory. + """ try: config_dir = _get_config_dir() # Check if config directory exists and is writable if not config_dir.exists(): - logger.warning(f"Config directory does not exist: {config_dir}") + logger.warning("Config directory does not exist: %s", config_dir) return False # Test writability @@ -419,7 +466,7 @@ def initialize_default_configs() -> bool: test_file.touch() test_file.unlink() except (OSError, PermissionError) as e: - logger.warning(f"Config directory is not writable: {config_dir} - {e}") + logger.warning("Config directory is not writable: %s - %s", config_dir, e) return False initialized_tabs = [] @@ -442,20 +489,20 @@ def initialize_default_configs() -> bool: if defaults: _ensure_config_dir(tab.name) try: - with open(config_path, 'w') as f: + with config_path.open("w") as f: json.dump(defaults, f, indent=2) initialized_tabs.append(tab.name) - except Exception as e: - logger.error(f"Failed to initialize config for {tab.name}: {e}") + except Exception: + logger.exception("Failed to initialize config for %s", tab.name) if initialized_tabs: - logger.info(f"Initialized default configs for: {initialized_tabs}") + logger.info("Initialized default configs for: %s", initialized_tabs) - return True - - except Exception as e: - logger.error(f"Error during config initialization: {e}") + except Exception: + logger.exception("Error during config initialization") return False + else: + return True def sync_env_to_config() -> None: @@ -465,24 +512,29 @@ def sync_env_to_config() -> None: for tab in get_all_settings_tabs(): values_to_sync = {} - for field in _iter_value_fields(tab): + for settings_field in _iter_value_fields(tab): # Skip fields that don't support ENV vars - if not getattr(field, 'env_supported', True): + if not getattr(settings_field, "env_supported", True): continue # Check if ENV var is set - env_var_name = field.get_env_var_name() + env_var_name = settings_field.get_env_var_name() env_value = os.environ.get(env_var_name) if env_value is not None: # Parse the ENV value to the appropriate type - parsed_value = _parse_env_value(env_value, field) - values_to_sync[field.key] = parsed_value + parsed_value = _parse_env_value(env_value, settings_field) + values_to_sync[settings_field.key] = parsed_value # Save synced values to config file (merge with existing) if values_to_sync: save_config_file(tab.name, values_to_sync) - logger.debug(f"Synced {len(values_to_sync)} ENV values to {tab.name} config: {list(values_to_sync.keys())}") + logger.debug( + "Synced %s ENV values to %s config: %s", + len(values_to_sync), + tab.name, + list(values_to_sync.keys()), + ) migrate_legacy_settings() migrate_download_to_browser_settings() @@ -490,8 +542,7 @@ def sync_env_to_config() -> None: def migrate_mirror_settings() -> None: - """ - Sync AA mirror list when code defaults change between versions. + """Sync AA mirror list when code defaults change between versions. On startup, compares a hash of DEFAULT_AA_MIRRORS against the hash stored in the config file. If they differ (i.e., an update shipped new defaults), @@ -534,10 +585,12 @@ def migrate_mirror_settings() -> None: # Defaults changed since last startup — push new mirrors to config if stored_hash != current_defaults_hash: - _save_mirrors({ - "AA_MIRROR_URLS": normalized_defaults, - "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, - }) + _save_mirrors( + { + "AA_MIRROR_URLS": normalized_defaults, + "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, + } + ) return # --- Legacy migration (only runs if hash already matches / first time) --- @@ -547,10 +600,12 @@ def migrate_mirror_settings() -> None: normalized = _normalize_list([str(v) for v in raw_list]) if normalized: return - _save_mirrors({ - "AA_MIRROR_URLS": normalized_defaults, - "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, - }) + _save_mirrors( + { + "AA_MIRROR_URLS": normalized_defaults, + "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, + } + ) return # If saved as a string, convert to list. @@ -558,15 +613,19 @@ def migrate_mirror_settings() -> None: parts = [p.strip() for p in raw_list.split(",") if p.strip()] normalized = _normalize_list(parts) if normalized: - _save_mirrors({ - "AA_MIRROR_URLS": normalized, - "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, - }) + _save_mirrors( + { + "AA_MIRROR_URLS": normalized, + "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, + } + ) return - _save_mirrors({ - "AA_MIRROR_URLS": normalized_defaults, - "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, - }) + _save_mirrors( + { + "AA_MIRROR_URLS": normalized_defaults, + "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, + } + ) return # If there's legacy additional mirrors, seed the full list. @@ -574,17 +633,21 @@ def migrate_mirror_settings() -> None: additional_parts = [p.strip() for p in raw_additional.split(",") if p.strip()] combined = _normalize_list(DEFAULT_AA_MIRRORS + additional_parts) if combined: - _save_mirrors({ - "AA_MIRROR_URLS": combined, - "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, - }) + _save_mirrors( + { + "AA_MIRROR_URLS": combined, + "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, + } + ) return # No config at all yet — write defaults - _save_mirrors({ - "AA_MIRROR_URLS": normalized_defaults, - "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, - }) + _save_mirrors( + { + "AA_MIRROR_URLS": normalized_defaults, + "_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash, + } + ) def migrate_legacy_settings() -> None: @@ -600,7 +663,6 @@ def migrate_legacy_settings() -> None: """ # Load existing downloads config downloads_config = load_config_file("downloads") - source_config = load_config_file("download_sources") # Skip migration if already using new settings if "FILE_ORGANIZATION" in downloads_config or "DESTINATION" in downloads_config: @@ -608,9 +670,16 @@ def migrate_legacy_settings() -> None: # Skip migration if no legacy settings exist (fresh install) legacy_keys = { - "PROCESSING_MODE", "INGEST_DIR", "LIBRARY_PATH", "USE_BOOK_TITLE", - "LIBRARY_TEMPLATE", "PROCESSING_MODE_AUDIOBOOK", "INGEST_DIR_AUDIOBOOK", - "LIBRARY_PATH_AUDIOBOOK", "LIBRARY_TEMPLATE_AUDIOBOOK", "TORRENT_HARDLINK", + "PROCESSING_MODE", + "INGEST_DIR", + "LIBRARY_PATH", + "USE_BOOK_TITLE", + "LIBRARY_TEMPLATE", + "PROCESSING_MODE_AUDIOBOOK", + "INGEST_DIR_AUDIOBOOK", + "LIBRARY_PATH_AUDIOBOOK", + "LIBRARY_TEMPLATE_AUDIOBOOK", + "TORRENT_HARDLINK", "USE_CONTENT_TYPE_DIRECTORIES", } if not any(key in downloads_config for key in legacy_keys): @@ -689,11 +758,11 @@ def migrate_legacy_settings() -> None: # Save migrated settings if migrated_downloads: save_config_file("downloads", migrated_downloads) - logger.info(f"Migrated download settings: {list(migrated_downloads.keys())}") + logger.info("Migrated download settings: %s", list(migrated_downloads.keys())) if migrated_sources: save_config_file("download_sources", migrated_sources) - logger.info(f"Migrated content-type routing settings: {list(migrated_sources.keys())}") + logger.info("Migrated content-type routing settings: %s", list(migrated_sources.keys())) def migrate_download_to_browser_settings() -> None: @@ -703,13 +772,17 @@ def migrate_download_to_browser_settings() -> None: new_key = "DOWNLOAD_TO_BROWSER_CONTENT_TYPES" config_path = _get_config_file_path("downloads") - legacy_value: Any = None + legacy_value: object = None legacy_present = False if legacy_key in downloads_config: legacy_value = downloads_config.get(legacy_key) legacy_present = True - elif new_key not in downloads_config and os.environ.get(new_key) is None and legacy_key in os.environ: + elif ( + new_key not in downloads_config + and os.environ.get(new_key) is None + and legacy_key in os.environ + ): legacy_value = os.environ.get(legacy_key) legacy_present = True @@ -740,14 +813,14 @@ def migrate_download_to_browser_settings() -> None: try: _ensure_config_dir("downloads") - with open(config_path, "w") as f: + with config_path.open("w") as f: json.dump(updated_downloads, f, indent=2) logger.info("Migrated download-to-browser setting to content-type selection") - except Exception as exc: - logger.error(f"Failed to migrate download-to-browser settings: {exc}") + except Exception: + logger.exception("Failed to migrate download-to-browser settings") -def get_setting_value(field: SettingsField, tab_name: str) -> Any: +def get_setting_value(field: SettingsField, tab_name: str) -> object: if isinstance(field, (ActionButton, HeadingField, CustomComponentField)): return None # Actions and headings don't have values @@ -767,27 +840,25 @@ def get_setting_value(field: SettingsField, tab_name: str) -> Any: return field.default -def _parse_env_value(value: str, field: SettingsField) -> Any: +def _parse_env_value(value: str, field: SettingsField) -> object: """Parse an environment variable value to the appropriate type.""" if isinstance(field, CheckboxField): - return value.lower() in ('true', '1', 'yes', 'on') - elif isinstance(field, NumberField): + return value.lower() in ("true", "1", "yes", "on") + if isinstance(field, NumberField): try: - if '.' in value: + if "." in value: return float(value) return int(value) except ValueError: return field.default - elif isinstance(field, MultiSelectField): - return [v.strip() for v in value.split(',') if v.strip()] - elif isinstance(field, TagListField): - return [v.strip() for v in value.split(',') if v.strip()] + elif isinstance(field, (MultiSelectField, TagListField)): + return [v.strip() for v in value.split(",") if v.strip()] elif isinstance(field, OrderableListField): # Parse JSON array: [{"id": "...", "enabled": true}, ...] try: return json.loads(value) except json.JSONDecodeError: - logger.warning(f"Invalid JSON for {field.key}, using default") + logger.warning("Invalid JSON for %s, using default", field.key) return field.default elif isinstance(field, TableField): # Parse JSON array: [{"col": "value"}, ...] @@ -795,7 +866,7 @@ def _parse_env_value(value: str, field: SettingsField) -> Any: parsed = json.loads(value) return parsed if isinstance(parsed, list) else field.default except json.JSONDecodeError: - logger.warning(f"Invalid JSON for {field.key}, using default") + logger.warning("Invalid JSON for %s, using default", field.key) return field.default else: return value @@ -806,14 +877,18 @@ def is_value_from_env(field: SettingsField) -> bool: if isinstance(field, (ActionButton, HeadingField, CustomComponentField)): return False # UI-only settings never come from ENV (env_supported=False) - if not getattr(field, 'env_supported', True): + if not getattr(field, "env_supported", True): return False return field.get_env_var_name() in os.environ -def serialize_field(field: SettingsField, tab_name: str, include_value: bool = True) -> Dict[str, Any]: - """ - Serialize a field for API response. +def serialize_field( + field: SettingsField, + tab_name: str, + *, + include_value: bool = True, +) -> dict[str, Any]: + """Serialize a field for API response. Args: field: The settings field. @@ -822,10 +897,11 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T Returns: Dict representation of the field. + """ # CustomComponentField has a custom structure - handle separately if isinstance(field, CustomComponentField): - result: Dict[str, Any] = { + result: dict[str, Any] = { "key": field.key, "label": field.label, "type": field.get_field_type(), @@ -855,7 +931,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T # HeadingField has a different structure - handle separately if isinstance(field, HeadingField): - result: Dict[str, Any] = { + result: dict[str, Any] = { "key": field.key, "type": field.get_field_type(), "title": field.title, @@ -872,25 +948,25 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T result["universalOnly"] = True return result - result: Dict[str, Any] = { + result: dict[str, Any] = { "key": field.key, "label": field.label, "type": field.get_field_type(), - "description": getattr(field, 'description', ''), - "required": getattr(field, 'required', False), - "disabled": getattr(field, 'disabled', False), - "disabledReason": getattr(field, 'disabled_reason', ''), - "requiresRestart": getattr(field, 'requires_restart', False), - "userOverridable": getattr(field, 'user_overridable', False), - "hiddenInUi": getattr(field, 'hidden_in_ui', False), + "description": getattr(field, "description", ""), + "required": getattr(field, "required", False), + "disabled": getattr(field, "disabled", False), + "disabledReason": getattr(field, "disabled_reason", ""), + "requiresRestart": getattr(field, "requires_restart", False), + "userOverridable": getattr(field, "user_overridable", False), + "hiddenInUi": getattr(field, "hidden_in_ui", False), } # Add optional properties if set - if getattr(field, 'show_when', None): + if getattr(field, "show_when", None): result["showWhen"] = field.show_when - if getattr(field, 'disabled_when', None): + if getattr(field, "disabled_when", None): result["disabledWhen"] = field.disabled_when - if getattr(field, 'universal_only', False): + if getattr(field, "universal_only", False): result["universalOnly"] = True # Add type-specific properties @@ -961,11 +1037,8 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T value = [v.strip() for v in value.split(",") if v.strip()] else: value = [] - elif isinstance(field, TableField): - if value is None: - value = [] - elif not isinstance(value, list): - value = [] + elif isinstance(field, TableField) and (value is None or not isinstance(value, list)): + value = [] result["value"] = value if value is not None else "" result["fromEnv"] = is_value_from_env(field) @@ -973,7 +1046,11 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T return result -def serialize_tab(tab: SettingsTab, include_values: bool = True) -> Dict[str, Any]: +def serialize_tab( + tab: SettingsTab, + *, + include_values: bool = True, +) -> dict[str, Any]: """Serialize a settings tab for API response.""" return { "name": tab.name, @@ -981,11 +1058,11 @@ def serialize_tab(tab: SettingsTab, include_values: bool = True) -> Dict[str, An "icon": tab.icon, "order": tab.order, "group": tab.group, - "fields": [serialize_field(f, tab.name, include_values) for f in tab.fields], + "fields": [serialize_field(f, tab.name, include_value=include_values) for f in tab.fields], } -def serialize_group(group: SettingsGroup) -> Dict[str, Any]: +def serialize_group(group: SettingsGroup) -> dict[str, Any]: """Serialize a settings group for API response.""" return { "name": group.name, @@ -995,24 +1072,25 @@ def serialize_group(group: SettingsGroup) -> Dict[str, Any]: } -def get_all_groups() -> List[SettingsGroup]: +def get_all_groups() -> list[SettingsGroup]: """Get all registered settings groups, sorted by order.""" return sorted(_GROUPS_REGISTRY.values(), key=lambda g: (g.order, g.name)) -def serialize_all_settings(include_values: bool = True) -> Dict[str, Any]: +def serialize_all_settings(*, include_values: bool = True) -> dict[str, Any]: """Serialize all settings for API response.""" tabs = get_all_settings_tabs() groups = get_all_groups() return { - "tabs": [serialize_tab(t, include_values) for t in tabs], + "tabs": [serialize_tab(t, include_values=include_values) for t in tabs], "groups": [serialize_group(g) for g in groups], } -def execute_action(tab_name: str, action_key: str, current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """ - Execute an action button's callback. +def execute_action( + tab_name: str, action_key: str, current_values: dict[str, Any] | None = None +) -> dict[str, Any]: + """Execute an action button's callback. Args: tab_name: The settings tab name. @@ -1022,6 +1100,7 @@ def execute_action(tab_name: str, action_key: str, current_values: Optional[Dict Returns: Dict with "success" (bool) and "message" (str). + """ import inspect @@ -1029,18 +1108,17 @@ def execute_action(tab_name: str, action_key: str, current_values: Optional[Dict if not tab: return {"success": False, "message": f"Unknown settings tab: {tab_name}"} - for field in tab.fields: - if isinstance(field, ActionButton) and field.key == action_key: - if field.callback: + for settings_field in tab.fields: + if isinstance(settings_field, ActionButton) and settings_field.key == action_key: + if settings_field.callback: try: # Check if callback accepts current_values parameter - sig = inspect.signature(field.callback) + sig = inspect.signature(settings_field.callback) if "current_values" in sig.parameters: - return field.callback(current_values=current_values or {}) - else: - return field.callback() + return settings_field.callback(current_values=current_values or {}) + return settings_field.callback() except Exception as e: - logger.error(f"Action {action_key} failed: {e}") + logger.exception("Action %s failed", action_key) return {"success": False, "message": str(e)} else: return {"success": False, "message": "Action has no callback defined"} @@ -1049,22 +1127,21 @@ def execute_action(tab_name: str, action_key: str, current_values: Optional[Dict def _sync_metadata_provider_selection() -> None: - """ - Sync the METADATA_PROVIDER setting based on enabled providers. + """Sync the METADATA_PROVIDER setting based on enabled providers. Called after saving metadata provider settings to auto-select the first enabled provider if the current selection is invalid. """ try: from shelfmark.metadata_providers import sync_metadata_provider_selection + sync_metadata_provider_selection() except ImportError: pass # Metadata providers module not available -def _apply_dns_settings(config) -> None: - """ - Apply DNS settings changes to the network module. +def _apply_dns_settings(config: Config) -> None: + """Apply DNS settings changes to the network module. This ensures DNS changes take effect immediately without requiring a container restart. @@ -1086,11 +1163,11 @@ def _apply_dns_settings(config) -> None: except ImportError: pass # Network module not available except Exception as e: - logger.warning(f"Failed to apply DNS settings: {e}") + logger.warning("Failed to apply DNS settings: %s", e) -def _apply_aa_mirror_settings(config) -> None: - """ - Apply AA mirror settings changes to the network module. + +def _apply_aa_mirror_settings(config: Config) -> None: + """Apply AA mirror settings changes to the network module. This ensures AA_BASE_URL / AA_ADDITIONAL_URLS changes take effect immediately without requiring a container restart. @@ -1103,18 +1180,22 @@ def _apply_aa_mirror_settings(config) -> None: except ImportError: pass # Network module not available except Exception as e: - logger.warning(f"Failed to apply AA mirror settings: {e}") + logger.warning("Failed to apply AA mirror settings: %s", e) -def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]: +def update_settings(tab_name: str, values: dict[str, Any]) -> dict[str, Any]: tab = get_settings_tab(tab_name) if not tab: - return {"success": False, "message": f"Unknown settings tab: {tab_name}", "updated": [], "requiresRestart": False} + return { + "success": False, + "message": f"Unknown settings tab: {tab_name}", + "updated": [], + "requiresRestart": False, + } # Build a map of field keys to fields (exclude non-value fields) field_map = { - key: field - for key, (field, _) in get_settings_field_map(tab_name=tab_name).items() + key: field for key, (field, _) in get_settings_field_map(tab_name=tab_name).items() } # Filter out values that are set via env vars or unknown @@ -1140,14 +1221,19 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]: values_to_save[key] = value # Track if this field requires restart - if getattr(field, 'requires_restart', False): + if getattr(field, "requires_restart", False): restart_required_keys.append(key) if not values_to_save: message = "No settings to update" if skipped_env: message += f". Skipped (set via env): {', '.join(skipped_env)}" - return {"success": True, "message": message, "updated": [], "requiresRestart": False} + return { + "success": True, + "message": message, + "updated": [], + "requiresRestart": False, + } # Call on_save handler if registered (for custom validation/transformation) on_save_handler = get_on_save_handler(tab_name) @@ -1159,17 +1245,17 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]: "success": False, "message": result.get("message", "Validation failed"), "updated": [], - "requiresRestart": False + "requiresRestart": False, } # Use the transformed values values_to_save = result.get("values", values_to_save) except Exception as e: - logger.error(f"on_save handler for {tab_name} failed: {e}") + logger.exception("on_save handler for %s failed", tab_name) return { "success": False, - "message": f"Save handler error: {str(e)}", + "message": f"Save handler error: {e!s}", "updated": [], - "requiresRestart": False + "requiresRestart": False, } # Save to config file @@ -1199,10 +1285,13 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]: and "CERTIFICATE_VALIDATION" in values_to_save ): try: - from shelfmark.download.network import _apply_ssl_warning_suppression + from shelfmark.download.network import ( + _apply_ssl_warning_suppression, + ) + _apply_ssl_warning_suppression() except Exception as e: - logger.warning(f"Failed to apply certificate validation setting: {e}") + logger.warning("Failed to apply certificate validation setting: %s", e) # Apply AA mirror settings changes live (mirrors tab) aa_keys = {"AA_BASE_URL", "AA_MIRROR_URLS", "AA_ADDITIONAL_URLS"} @@ -1230,5 +1319,9 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]: "requiresRestart": requires_restart, "restartRequiredFor": restart_required_keys, } - else: - return {"success": False, "message": "Failed to save settings", "updated": [], "requiresRestart": False} + return { + "success": False, + "message": "Failed to save settings", + "updated": [], + "requiresRestart": False, + } diff --git a/shelfmark/core/user_db.py b/shelfmark/core/user_db.py index 5c596509..09b12c05 100644 --- a/shelfmark/core/user_db.py +++ b/shelfmark/core/user_db.py @@ -4,12 +4,12 @@ import json import os import sqlite3 import threading -from typing import Any, Dict, List, Optional +from pathlib import Path +from typing import Any, ClassVar -from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET from shelfmark.core.activity_view_state_service import user_viewer_scope +from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET from shelfmark.core.logger import setup_logger -from shelfmark.core.request_helpers import normalize_optional_positive_int from shelfmark.core.models import QueueStatus from shelfmark.core.request_validation import ( DELIVERY_STATE_NONE, @@ -116,16 +116,16 @@ WHERE dismissed_at IS NOT NULL; """ -def get_users_db_path(config_dir: Optional[str] = None) -> str: +def get_users_db_path(config_dir: str | None = None) -> str: """Return the configured users database path.""" root = config_dir or os.environ.get("CONFIG_DIR", "/config") - return os.path.join(root, "users.db") + return str(Path(root) / "users.db") def sync_builtin_admin_user( username: str, password_hash: str, - db_path: Optional[str] = None, + db_path: str | None = None, ) -> None: """Ensure a local admin user exists for configured builtin credentials.""" normalized_username = (username or "").strip() @@ -138,7 +138,9 @@ def sync_builtin_admin_user( existing = user_db.get_user(username=normalized_username) if existing: - existing_auth_source = str(existing.get("auth_source") or AUTH_SOURCE_BUILTIN).strip().lower() + existing_auth_source = ( + str(existing.get("auth_source") or AUTH_SOURCE_BUILTIN).strip().lower() + ) if existing_auth_source != AUTH_SOURCE_BUILTIN: logger.warning( "Skipped builtin admin sync for username '%s' because it belongs to auth_source='%s'", @@ -155,7 +157,7 @@ def sync_builtin_admin_user( updates["auth_source"] = AUTH_SOURCE_BUILTIN if updates: user_db.update_user(existing["id"], **updates) - logger.info(f"Updated local admin user '{normalized_username}' from builtin settings") + logger.info("Updated local admin user '%s' from builtin settings", normalized_username) return user_db.create_user( @@ -164,15 +166,15 @@ def sync_builtin_admin_user( auth_source=AUTH_SOURCE_BUILTIN, role="admin", ) - logger.info(f"Created local admin user '{normalized_username}' from builtin settings") + logger.info("Created local admin user '%s' from builtin settings", normalized_username) class UserDB: """Thread-safe SQLite user database.""" - _VALID_AUTH_SOURCES = set(AUTH_SOURCE_SET) + _VALID_AUTH_SOURCES: ClassVar[frozenset[str]] = frozenset(AUTH_SOURCE_SET) - def __init__(self, db_path: str): + def __init__(self, db_path: str) -> None: self._db_path = db_path self._lock = threading.Lock() @@ -204,14 +206,10 @@ class UserDB: column_names = {str(col["name"]) for col in columns} if "auth_source" not in column_names: - conn.execute( - "ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin'" - ) + conn.execute("ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin'") # Backfill OIDC-origin users created before auth_source existed. - conn.execute( - "UPDATE users SET auth_source = 'oidc' WHERE oidc_subject IS NOT NULL" - ) + conn.execute("UPDATE users SET auth_source = 'oidc' WHERE oidc_subject IS NOT NULL") # Defensive cleanup for any legacy null/blank values. conn.execute( "UPDATE users SET auth_source = 'builtin' WHERE auth_source IS NULL OR auth_source = ''" @@ -266,13 +264,13 @@ class UserDB: def create_user( self, username: str, - email: Optional[str] = None, - display_name: Optional[str] = None, - password_hash: Optional[str] = None, - oidc_subject: Optional[str] = None, + email: str | None = None, + display_name: str | None = None, + password_hash: str | None = None, + oidc_subject: str | None = None, auth_source: str = "builtin", role: str = "user", - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Create a new user. Raises ValueError if username or oidc_subject already exists.""" if auth_source not in self._VALID_AUTH_SOURCES: raise ValueError(f"Invalid auth_source: {auth_source}") @@ -298,25 +296,23 @@ class UserDB: user_id = cursor.lastrowid return self._get_user_by_id(conn, user_id) except sqlite3.IntegrityError as e: - raise ValueError(f"User already exists: {e}") + raise ValueError(f"User already exists: {e}") from e finally: conn.close() def get_user( self, - user_id: Optional[int] = None, - username: Optional[str] = None, - oidc_subject: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: + user_id: int | None = None, + username: str | None = None, + oidc_subject: str | None = None, + ) -> dict[str, Any] | None: """Get a user by id, username, or oidc_subject. Returns None if not found.""" conn = self._connect() try: if user_id is not None: return self._get_user_by_id(conn, user_id) - elif username is not None: - row = conn.execute( - "SELECT * FROM users WHERE username = ?", (username,) - ).fetchone() + if username is not None: + row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() elif oidc_subject is not None: row = conn.execute( "SELECT * FROM users WHERE oidc_subject = ?", (oidc_subject,) @@ -327,18 +323,20 @@ class UserDB: finally: conn.close() - def _get_user_by_id(self, conn: sqlite3.Connection, user_id: int) -> Optional[Dict[str, Any]]: + def _get_user_by_id(self, conn: sqlite3.Connection, user_id: int) -> dict[str, Any] | None: row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() return dict(row) if row else None - _ALLOWED_UPDATE_COLUMNS = { - "email", - "display_name", - "password_hash", - "oidc_subject", - "auth_source", - "role", - } + _ALLOWED_UPDATE_COLUMNS: ClassVar[frozenset[str]] = frozenset( + { + "email", + "display_name", + "password_hash", + "oidc_subject", + "auth_source", + "role", + } + ) def update_user(self, user_id: int, **kwargs) -> None: """Update user fields. Raises ValueError if user not found or invalid column.""" @@ -356,7 +354,7 @@ class UserDB: if not self._get_user_by_id(conn, user_id): raise ValueError(f"User {user_id} not found") sets = ", ".join(f"{k} = ?" for k in kwargs) - values = list(kwargs.values()) + [user_id] + values = [*list(kwargs.values()), user_id] conn.execute(f"UPDATE users SET {sets} WHERE id = ?", values) conn.commit() finally: @@ -386,13 +384,16 @@ class UserDB: "DELETE FROM activity_view_state WHERE viewer_scope = ?", (user_viewer_scope(user_id),), ) - conn.execute("UPDATE download_requests SET reviewed_by = NULL WHERE reviewed_by = ?", (user_id,)) + conn.execute( + "UPDATE download_requests SET reviewed_by = NULL WHERE reviewed_by = ?", + (user_id,), + ) conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) conn.commit() finally: conn.close() - def list_users(self) -> List[Dict[str, Any]]: + def list_users(self) -> list[dict[str, Any]]: """List all users.""" conn = self._connect() try: @@ -414,7 +415,7 @@ class UserDB: finally: conn.close() - def get_user_settings(self, user_id: int) -> Dict[str, Any]: + def get_user_settings(self, user_id: int) -> dict[str, Any]: """Get per-user settings. Returns empty dict if none set.""" conn = self._connect() try: @@ -427,7 +428,7 @@ class UserDB: finally: conn.close() - def set_user_settings(self, user_id: int, settings: Dict[str, Any]) -> None: + def set_user_settings(self, user_id: int, settings: dict[str, Any]) -> None: """Merge settings into user's existing settings.""" with self._lock: conn = self._connect() @@ -454,7 +455,7 @@ class UserDB: conn.close() @staticmethod - def _serialize_json(value: Any, field: str) -> Optional[str]: + def _serialize_json(value: Any, field: str) -> str | None: if value is None: return None try: @@ -463,7 +464,7 @@ class UserDB: raise ValueError(f"{field} must be JSON-serializable") from exc @staticmethod - def _parse_request_row(row: Optional[sqlite3.Row]) -> Optional[Dict[str, Any]]: + def _parse_request_row(row: sqlite3.Row | None) -> dict[str, Any] | None: if row is None: return None @@ -475,7 +476,7 @@ class UserDB: continue try: payload[key] = json.loads(raw_value) - except (ValueError, TypeError): + except ValueError, TypeError: payload[key] = None return payload @@ -487,17 +488,17 @@ class UserDB: content_type: str, request_level: str, policy_mode: str, - book_data: Dict[str, Any], - release_data: Optional[Dict[str, Any]] = None, + book_data: dict[str, Any], + release_data: dict[str, Any] | None = None, status: str = RequestStatus.PENDING, - source_hint: Optional[str] = None, - note: Optional[str] = None, - admin_note: Optional[str] = None, - reviewed_by: Optional[int] = None, - reviewed_at: Optional[str] = None, + source_hint: str | None = None, + note: str | None = None, + admin_note: str | None = None, + reviewed_by: int | None = None, + reviewed_at: str | None = None, delivery_state: str = DELIVERY_STATE_NONE, - delivery_updated_at: Optional[str] = None, - ) -> Dict[str, Any]: + delivery_updated_at: str | None = None, + ) -> dict[str, Any]: cursor = conn.execute( """ INSERT INTO download_requests ( @@ -552,22 +553,22 @@ class UserDB: content_type: str, request_level: str, policy_mode: str, - book_data: Dict[str, Any], - release_data: Optional[Dict[str, Any]] = None, + book_data: dict[str, Any], + release_data: dict[str, Any] | None = None, status: str = RequestStatus.PENDING, - source_hint: Optional[str] = None, - note: Optional[str] = None, - admin_note: Optional[str] = None, - reviewed_by: Optional[int] = None, - reviewed_at: Optional[str] = None, + source_hint: str | None = None, + note: str | None = None, + admin_note: str | None = None, + reviewed_by: int | None = None, + reviewed_at: str | None = None, delivery_state: str = DELIVERY_STATE_NONE, - delivery_updated_at: Optional[str] = None, - ) -> Dict[str, Any]: + delivery_updated_at: str | None = None, + ) -> dict[str, Any]: """Create a download request row and return the created record.""" if not isinstance(book_data, dict): - raise ValueError("book_data must be an object") + raise TypeError("book_data must be an object") if release_data is not None and not isinstance(release_data, dict): - raise ValueError("release_data must be an object when provided") + raise TypeError("release_data must be an object when provided") if not content_type: raise ValueError("content_type is required") @@ -601,20 +602,18 @@ class UserDB: finally: conn.close() - def create_requests(self, requests: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def create_requests(self, requests: list[dict[str, Any]]) -> list[dict[str, Any]]: """Create multiple request rows atomically and return them in input order.""" with self._lock: conn = self._connect() try: - created: List[Dict[str, Any]] = [] - for request in requests: - created.append(self._insert_request(conn, **request)) + created = [self._insert_request(conn, **request) for request in requests] conn.commit() return created finally: conn.close() - def get_request(self, request_id: int) -> Optional[Dict[str, Any]]: + def get_request(self, request_id: int) -> dict[str, Any] | None: """Get a request row by ID.""" conn = self._connect() try: @@ -629,14 +628,14 @@ class UserDB: def list_requests( self, *, - user_id: Optional[int] = None, - status: Optional[str] = None, - limit: Optional[int] = None, + user_id: int | None = None, + status: str | None = None, + limit: int | None = None, offset: int = 0, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """List requests with optional user/status filters.""" - where_clauses: List[str] = [] - params: List[Any] = [] + where_clauses: list[str] = [] + params: list[Any] = [] if user_id is not None: where_clauses.append("user_id = ?") @@ -664,7 +663,7 @@ class UserDB: conn = self._connect() try: rows = conn.execute(query, params).fetchall() - results: List[Dict[str, Any]] = [] + results: list[dict[str, Any]] = [] for row in rows: parsed = self._parse_request_row(row) if parsed is not None: @@ -673,29 +672,31 @@ class UserDB: finally: conn.close() - _ALLOWED_REQUEST_UPDATE_COLUMNS = { - "status", - "source_hint", - "content_type", - "request_level", - "policy_mode", - "book_data", - "release_data", - "note", - "admin_note", - "reviewed_by", - "reviewed_at", - "delivery_state", - "delivery_updated_at", - "last_failure_reason", - } + _ALLOWED_REQUEST_UPDATE_COLUMNS: ClassVar[frozenset[str]] = frozenset( + { + "status", + "source_hint", + "content_type", + "request_level", + "policy_mode", + "book_data", + "release_data", + "note", + "admin_note", + "reviewed_by", + "reviewed_at", + "delivery_state", + "delivery_updated_at", + "last_failure_reason", + } + ) def update_request( self, request_id: int, - expected_current_status: Optional[str] = None, + expected_current_status: str | None = None, **kwargs, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Update request fields and return the updated record.""" if not kwargs: request = self.get_request(request_id) @@ -745,7 +746,7 @@ class UserDB: if "delivery_updated_at" in updates: delivery_updated_at = updates["delivery_updated_at"] if delivery_updated_at is not None and not isinstance(delivery_updated_at, str): - raise ValueError("delivery_updated_at must be a string when provided") + raise TypeError("delivery_updated_at must be a string when provided") if "content_type" in updates and not updates["content_type"]: raise ValueError("content_type is required") @@ -755,19 +756,21 @@ class UserDB: if "book_data" in updates: if not isinstance(updates["book_data"], dict): - raise ValueError("book_data must be an object") + raise TypeError("book_data must be an object") updates["book_data"] = self._serialize_json(updates["book_data"], "book_data") if "release_data" in updates: - if updates["release_data"] is not None and not isinstance(updates["release_data"], dict): - raise ValueError("release_data must be an object when provided") + if updates["release_data"] is not None and not isinstance( + updates["release_data"], dict + ): + raise TypeError("release_data must be an object when provided") updates["release_data"] = self._serialize_json( updates["release_data"], "release_data", ) set_clause = ", ".join(f"{column} = ?" for column in updates) - values = list(updates.values()) + [request_id] + values = [*list(updates.values()), request_id] conn.execute( f"UPDATE download_requests SET {set_clause} WHERE id = ?", values, @@ -789,8 +792,8 @@ class UserDB: self, request_id: int, *, - failure_reason: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: + failure_reason: str | None = None, + ) -> dict[str, Any] | None: """Reopen a failed fulfilled request so admins can re-approve it.""" normalized_failure_reason = None if isinstance(failure_reason, str): @@ -849,9 +852,9 @@ class UserDB: self, request_id: int, *, - release_data: Optional[Dict[str, Any]], - last_failure_reason: Optional[str] = None, - ) -> Dict[str, Any]: + release_data: dict[str, Any] | None, + last_failure_reason: str | None = None, + ) -> dict[str, Any]: """Restore a request to pending after fulfilment claimed it but queueing failed.""" with self._lock: conn = self._connect() diff --git a/shelfmark/core/user_settings_overrides.py b/shelfmark/core/user_settings_overrides.py index aeb0f0a0..ee389b34 100644 --- a/shelfmark/core/user_settings_overrides.py +++ b/shelfmark/core/user_settings_overrides.py @@ -1,17 +1,22 @@ """Shared helpers for user-overridable settings metadata and payloads.""" -from typing import Any +from importlib import import_module +from typing import TYPE_CHECKING, Any from shelfmark.core.settings_registry import load_config_file -from shelfmark.core.user_db import UserDB + +if TYPE_CHECKING: + from types import ModuleType + + from shelfmark.core.user_db import UserDB -def get_settings_registry(): +def get_settings_registry() -> ModuleType: # Ensure settings modules are loaded before reading registry metadata. - import shelfmark.config.settings # noqa: F401 - import shelfmark.config.security # noqa: F401 - import shelfmark.config.notifications_settings # noqa: F401 - import shelfmark.config.users_settings # noqa: F401 + import_module("shelfmark.config.notifications_settings") + import_module("shelfmark.config.security") + import_module("shelfmark.config.settings") + import_module("shelfmark.config.users_settings") from shelfmark.core import settings_registry return settings_registry @@ -33,7 +38,8 @@ def build_user_preferences_payload(user_db: UserDB, user_id: int, tab_name: str) ordered_fields = get_ordered_user_overridable_fields(tab_name) if not ordered_fields: tab_label = tab_name.capitalize() - raise ValueError(f"{tab_label} settings tab not found") + msg = f"{tab_label} settings tab not found" + raise ValueError(msg) tab_config = load_config_file(tab_name) user_settings = user_db.get_user_settings(user_id) @@ -45,7 +51,9 @@ def build_user_preferences_payload(user_db: UserDB, user_id: int, tab_name: str) for key, field in ordered_fields: serialized = settings_registry.serialize_field(field, tab_name, include_value=False) - serialized["fromEnv"] = bool(field.env_supported and settings_registry.is_value_from_env(field)) + serialized["fromEnv"] = bool( + field.env_supported and settings_registry.is_value_from_env(field) + ) fields_payload.append(serialized) global_values[key] = app_config.get(key, field.default) diff --git a/shelfmark/core/utils.py b/shelfmark/core/utils.py index 8f75b0ef..45901a5f 100644 --- a/shelfmark/core/utils.py +++ b/shelfmark/core/utils.py @@ -4,15 +4,17 @@ import base64 import importlib import os import re -from threading import Lock -from types import ModuleType from pathlib import Path -from typing import Optional +from threading import Lock +from typing import TYPE_CHECKING from urllib.parse import urlparse +if TYPE_CHECKING: + from types import ModuleType + def normalize_http_url( - url: Optional[str], + url: str | None, *, default_scheme: str = "http", strip_trailing_slash: bool = True, @@ -26,7 +28,7 @@ def normalize_http_url( if not normalized: return "" - if (normalized.startswith("\"") and normalized.endswith("\"")) or ( + if (normalized.startswith('"') and normalized.endswith('"')) or ( normalized.startswith("'") and normalized.endswith("'") ): normalized = normalized[1:-1].strip() @@ -34,11 +36,7 @@ def normalize_http_url( return "" if allow_special: - special_map = { - value.lower(): value - for value in allow_special - if isinstance(value, str) - } + special_map = {value.lower(): value for value in allow_special if isinstance(value, str)} special_match = special_map.get(normalized.lower()) if special_match is not None: return special_match @@ -79,7 +77,7 @@ def get_hardened_xmlrpc_client() -> ModuleType: return importlib.import_module("xmlrpc.client") -def normalize_base_path(value: Optional[str]) -> str: +def normalize_base_path(value: str | None) -> str: """Normalize a URL base path for reverse proxy subpath deployments.""" if not isinstance(value, str): return "" @@ -101,7 +99,7 @@ def normalize_base_path(value: Optional[str]) -> str: return path.rstrip("/") -def is_audiobook(content_type: Optional[str]) -> bool: +def is_audiobook(content_type: str | None) -> bool: """Check if content type indicates an audiobook.""" return bool(content_type and "audiobook" in content_type.lower()) @@ -156,8 +154,8 @@ def _sanitize_user_for_path(username: str) -> str: def _resolve_destination_username( - user_id: Optional[int] = None, - username: Optional[str] = None, + user_id: int | None = None, + username: str | None = None, ) -> str: explicit = str(username or "").strip() if explicit: @@ -169,7 +167,7 @@ def _resolve_destination_username( try: from shelfmark.core.user_db import UserDB - user_db = UserDB(os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db")) + user_db = UserDB(str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db")) user_db.initialize() user = user_db.get_user(user_id=user_id) if not user: @@ -181,8 +179,8 @@ def _resolve_destination_username( def _expand_user_destination_placeholder( path_value: str, - user_id: Optional[int] = None, - username: Optional[str] = None, + user_id: int | None = None, + username: str | None = None, ) -> str: """Expand `{User}` placeholders in destination paths.""" if not isinstance(path_value, str): @@ -198,9 +196,10 @@ def _expand_user_destination_placeholder( def get_destination( + *, is_audiobook: bool = False, - user_id: Optional[int] = None, - username: Optional[str] = None, + user_id: int | None = None, + username: str | None = None, ) -> Path: """Get base destination directory. Audiobooks fall back to main destination.""" from shelfmark.core.config import config @@ -219,7 +218,9 @@ def get_destination( # Main destination (also fallback for audiobooks) # Check new setting first, then legacy INGEST_DIR - destination = config.get("DESTINATION", "", user_id=user_id) or config.get("INGEST_DIR", "/books") + destination = config.get("DESTINATION", "", user_id=user_id) or config.get( + "INGEST_DIR", "/books" + ) return Path( _expand_user_destination_placeholder( str(destination), @@ -229,12 +230,14 @@ def get_destination( ) -def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path]: +def get_aa_content_type_dir(content_type: str | None = None) -> Path | None: """Get override directory for AA content-type routing if configured.""" from shelfmark.core.config import config # Check if content-type routing is enabled (new or legacy setting) - if not config.get("AA_CONTENT_TYPE_ROUTING", False) and not config.get("USE_CONTENT_TYPE_DIRECTORIES", False): + if not config.get("AA_CONTENT_TYPE_ROUTING", False) and not config.get( + "USE_CONTENT_TYPE_DIRECTORIES", False + ): return None if not content_type: @@ -253,7 +256,7 @@ def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path return None -def get_ingest_dir(content_type: Optional[str] = None) -> Path: +def get_ingest_dir(content_type: str | None = None) -> Path: """DEPRECATED: Use get_destination() and get_aa_content_type_dir() instead.""" from shelfmark.core.config import config @@ -271,17 +274,18 @@ def get_ingest_dir(content_type: Optional[str] = None) -> Path: return default_ingest_dir -def transform_cover_url(cover_url: Optional[str], cache_id: str) -> Optional[str]: +def transform_cover_url(cover_url: str | None, cache_id: str) -> str | None: """Transform external cover URL to local proxy URL when caching is enabled.""" if not cover_url: return cover_url # Skip if already a local URL (starts with /) - if cover_url.startswith('/'): + if cover_url.startswith("/"): return cover_url # Check if cover caching is enabled from shelfmark.config.env import is_covers_cache_enabled + if not is_covers_cache_enabled(): return cover_url diff --git a/shelfmark/download/archive.py b/shelfmark/download/archive.py index 817a05c7..db7c5c64 100644 --- a/shelfmark/download/archive.py +++ b/shelfmark/download/archive.py @@ -1,21 +1,43 @@ """Archive extraction utilities for downloaded book archives.""" -import os -import shutil import zipfile from pathlib import Path -from typing import List, Optional, Tuple +from typing import TYPE_CHECKING from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import is_audiobook as check_audiobook +from shelfmark.download.fs import atomic_write from shelfmark.download.postprocess.policy import ( get_supported_audiobook_formats, get_supported_formats, ) -from shelfmark.core.utils import is_audiobook as check_audiobook -from shelfmark.download.fs import atomic_write logger = setup_logger(__name__) +if TYPE_CHECKING: + import rarfile + + ArchiveType = zipfile.ZipFile | rarfile.RarFile +else: + ArchiveType = zipfile.ZipFile + + +def _delete_file_with_logging(file_path: Path, file_type_label: str, *, rejected: bool) -> None: + """Delete a file and log the outcome.""" + try: + file_path.unlink() + if rejected: + logger.debug("Deleted rejected %s file: %s", file_type_label, file_path.name) + else: + logger.debug("Deleted non-%s file: %s", file_type_label, file_path.name) + except OSError as e: + if rejected: + logger.warning( + "Failed to delete rejected %s file %s: %s", file_type_label, file_path, e + ) + else: + logger.warning("Failed to delete non-%s file %s: %s", file_type_label, file_path, e) + # Check for rarfile availability at module load try: @@ -30,20 +52,14 @@ except ImportError: class ArchiveExtractionError(Exception): """Raised when archive extraction fails.""" - pass - class PasswordProtectedError(ArchiveExtractionError): """Raised when archive requires a password.""" - pass - class CorruptedArchiveError(ArchiveExtractionError): """Raised when archive is corrupted.""" - pass - def is_archive(file_path: Path) -> bool: """Check if file is a supported archive format.""" @@ -51,7 +67,7 @@ def is_archive(file_path: Path) -> bool: return suffix in ("zip", "rar") -def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> bool: +def _is_supported_file(file_path: Path, content_type: str | None = None) -> bool: """Check if file matches user's supported formats setting based on content type.""" ext = file_path.suffix.lower().lstrip(".") if check_audiobook(content_type): @@ -62,16 +78,30 @@ def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> b # All known ebook extensions (superset of what user might enable) -ALL_EBOOK_EXTENSIONS = {'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', '.doc', '.docx', '.rtf', '.txt'} +ALL_EBOOK_EXTENSIONS = { + ".pdf", + ".epub", + ".mobi", + ".azw", + ".azw3", + ".fb2", + ".djvu", + ".cbz", + ".cbr", + ".doc", + ".docx", + ".rtf", + ".txt", +} # All known audio extensions (superset of what user might enable for audiobooks) -ALL_AUDIO_EXTENSIONS = {'.m4b', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.wma', '.wav', '.opus'} +ALL_AUDIO_EXTENSIONS = {".m4b", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".wma", ".wav", ".opus"} def _filter_files( - extracted_files: List[Path], - content_type: Optional[str] = None, -) -> Tuple[List[Path], List[Path], List[Path]]: + extracted_files: list[Path], + content_type: str | None = None, +) -> tuple[list[Path], list[Path], list[Path]]: """Filter files by content type. Returns (matched, rejected_format, other).""" is_audiobook = check_audiobook(content_type) known_extensions = ALL_AUDIO_EXTENSIONS if is_audiobook else ALL_EBOOK_EXTENSIONS @@ -94,8 +124,8 @@ def _filter_files( def extract_archive( archive_path: Path, output_dir: Path, - content_type: Optional[str] = None, -) -> Tuple[List[Path], List[str], List[Path]]: + content_type: str | None = None, +) -> tuple[list[Path], list[str], list[Path]]: """Extract archive and filter by content type. Returns (matched, warnings, rejected).""" suffix = archive_path.suffix.lower().lstrip(".") @@ -114,23 +144,17 @@ def extract_archive( # Delete rejected files (valid formats but not enabled by user) for rejected_file in rejected_files: - try: - rejected_file.unlink() - logger.debug(f"Deleted rejected {file_type_label} file: {rejected_file.name}") - except OSError as e: - logger.warning(f"Failed to delete rejected {file_type_label} file {rejected_file}: {e}") + _delete_file_with_logging(rejected_file, file_type_label, rejected=True) if rejected_files: - rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) - warnings.append(f"Skipped {len(rejected_files)} {file_type_label}(s) with unsupported format: {', '.join(rejected_exts)}") + rejected_exts = sorted({f.suffix.lower() for f in rejected_files}) + warnings.append( + f"Skipped {len(rejected_files)} {file_type_label}(s) with unsupported format: {', '.join(rejected_exts)}" + ) # Delete other files (images, html, etc) for other_file in other_files: - try: - other_file.unlink() - logger.debug(f"Deleted non-{file_type_label} file: {other_file.name}") - except OSError as e: - logger.warning(f"Failed to delete non-{file_type_label} file {other_file}: {e}") + _delete_file_with_logging(other_file, file_type_label, rejected=False) if other_files: warnings.append(f"Skipped {len(other_files)} non-{file_type_label} file(s)") @@ -141,7 +165,7 @@ def extract_archive( def extract_archive_raw( archive_path: Path, output_dir: Path, -) -> Tuple[List[Path], List[str]]: +) -> tuple[list[Path], list[str]]: """Extract archive without filtering (returns all extracted files).""" suffix = archive_path.suffix.lower().lstrip(".") @@ -153,7 +177,7 @@ def extract_archive_raw( raise ArchiveExtractionError(f"Unsupported archive format: {suffix}") -def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]: +def _extract_files_from_archive(archive: ArchiveType, output_dir: Path) -> list[Path]: """Extract files from ZipFile or RarFile to output_dir with security checks.""" extracted_files = [] @@ -169,7 +193,7 @@ def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]: # Security: reject filenames with null bytes or path separators # Check both / and \ since archives may be created on different OSes if "\x00" in filename or "/" in filename or "\\" in filename: - logger.warning(f"Skipping suspicious filename in archive: {info.filename!r}") + logger.warning("Skipping suspicious filename in archive: %r", info.filename) continue # Extract to output_dir with flat structure @@ -179,19 +203,19 @@ def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]: try: target_path.resolve().relative_to(output_dir.resolve()) except ValueError: - logger.warning(f"Path traversal attempt blocked: {info.filename!r}") + logger.warning("Path traversal attempt blocked: %r", info.filename) continue with archive.open(info) as src: data = src.read() final_path = atomic_write(target_path, data) extracted_files.append(final_path) - logger.debug(f"Extracted: {filename}") + logger.debug("Extracted: %s", filename) return extracted_files -def _extract_zip(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List[str]]: +def _extract_zip(archive_path: Path, output_dir: Path) -> tuple[list[Path], list[str]]: """Extract files from a ZIP archive.""" try: with zipfile.ZipFile(archive_path, "r") as zf: @@ -208,12 +232,12 @@ def _extract_zip(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List return _extract_files_from_archive(zf, output_dir), [] except zipfile.BadZipFile as e: - raise CorruptedArchiveError(f"Invalid or corrupted ZIP: {e}") + raise CorruptedArchiveError(f"Invalid or corrupted ZIP: {e}") from e except PermissionError as e: - raise ArchiveExtractionError(f"Permission denied: {e}") + raise ArchiveExtractionError(f"Permission denied: {e}") from e -def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List[str]]: +def _extract_rar(archive_path: Path, output_dir: Path) -> tuple[list[Path], list[str]]: """Extract files from a RAR archive.""" if not RAR_AVAILABLE: raise ArchiveExtractionError("RAR extraction not available - rarfile library not installed") @@ -230,10 +254,8 @@ def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List return _extract_files_from_archive(rf, output_dir), [] except rarfile.BadRarFile as e: - raise CorruptedArchiveError(f"Invalid or corrupted RAR: {e}") - except rarfile.RarCannotExec: - raise ArchiveExtractionError("unrar binary not found - install unrar package") + raise CorruptedArchiveError(f"Invalid or corrupted RAR: {e}") from e + except rarfile.RarCannotExec as e: + raise ArchiveExtractionError("unrar binary not found - install unrar package") from e except PermissionError as e: - raise ArchiveExtractionError(f"Permission denied: {e}") - - + raise ArchiveExtractionError(f"Permission denied: {e}") from e diff --git a/shelfmark/download/clients/__init__.py b/shelfmark/download/clients/__init__.py index 3373ec06..2c49a9fc 100644 --- a/shelfmark/download/clients/__init__.py +++ b/shelfmark/download/clients/__init__.py @@ -1,5 +1,4 @@ -""" -Shared download client infrastructure for external release sources. +"""Shared download client infrastructure for external release sources. This module provides: - DownloadState: Enum of valid download states @@ -18,14 +17,18 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum from functools import wraps -from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, cast, Any +from pathlib import Path +from typing import TYPE_CHECKING, TypeVar, cast import requests +if TYPE_CHECKING: + from collections.abc import Callable + _logger = logging.getLogger(__name__) # Type variable for generic return type -T = TypeVar('T') +T = TypeVar("T") # Exceptions that should trigger a retry RETRYABLE_EXCEPTIONS = ( @@ -33,6 +36,9 @@ RETRYABLE_EXCEPTIONS = ( requests.exceptions.Timeout, requests.exceptions.HTTPError, ) +_MIN_RETRYABLE_STATUS = 500 +_MIN_PROGRESS_PERCENT = 0 +_MAX_PROGRESS_PERCENT = 100 def with_retry( @@ -41,8 +47,7 @@ def with_retry( max_delay: float = 10.0, jitter: float = 0.5, ) -> Callable[[Callable[..., T]], Callable[..., T]]: - """ - Decorator for retrying API calls with exponential backoff. + """Decorator for retrying API calls with exponential backoff. Args: max_attempts: Maximum number of attempts (default 3) @@ -58,7 +63,9 @@ def with_retry( Does NOT retry on: - HTTP 4xx client errors (bad request, auth failures) - Other exceptions (programming errors) + """ + def decorator(func: Callable[..., T]) -> Callable[..., T]: @wraps(func) def wrapper(*args, **kwargs) -> T: @@ -69,7 +76,7 @@ def with_retry( return func(*args, **kwargs) except requests.exceptions.HTTPError as e: # Only retry on server errors (5xx), not client errors (4xx) - if e.response is not None and e.response.status_code < 500: + if e.response is not None and e.response.status_code < _MIN_RETRYABLE_STATUS: raise last_exception = e except RETRYABLE_EXCEPTIONS as e: @@ -81,17 +88,23 @@ def with_retry( # Add jitter to prevent thundering herd delay += random.uniform(0, delay * jitter) _logger.debug( - f"Retry {attempt}/{max_attempts} for {func.__name__} " - f"after {delay:.1f}s (error: {last_exception})" + "Retry %s/%s for %s after %.1fs (error: %s)", + attempt, + max_attempts, + func.__name__, + delay, + last_exception, ) time.sleep(delay) # All retries exhausted if last_exception is None: - raise RuntimeError("Retry failed without exception") - raise cast(Exception, last_exception) + msg = "Retry failed without exception" + raise RuntimeError(msg) + raise cast("Exception", last_exception) return wrapper + return decorator @@ -114,15 +127,15 @@ class DownloadStatus: """Status of an external download (immutable).""" progress: float # 0-100 - state: Union[DownloadState, str] # Prefer DownloadState enum; strings auto-normalized - message: Optional[str] # Status message + state: DownloadState | str # Prefer DownloadState enum; strings auto-normalized + message: str | None # Status message complete: bool # True when download finished - file_path: Optional[str] # Path in client's download dir (when complete) - download_speed: Optional[int] = None # Bytes per second - eta: Optional[int] = None # Seconds remaining + file_path: str | None # Path in client's download dir (when complete) + download_speed: int | None = None # Bytes per second + eta: int | None = None # Seconds remaining @classmethod - def error(cls, message: str) -> "DownloadStatus": + def error(cls, message: str) -> DownloadStatus: """Create an error status.""" return cls( progress=0, @@ -132,21 +145,25 @@ class DownloadStatus: file_path=None, ) - def __post_init__(self): + def __post_init__(self) -> None: """Validate and normalize state.""" # Normalize string states to enum if isinstance(self.state, str): try: normalized_state = DownloadState(self.state) - object.__setattr__(self, 'state', normalized_state) + object.__setattr__(self, "state", normalized_state) except ValueError: # Unknown state string - keep as-is for backwards compatibility - _logger.warning(f"Unknown download state '{self.state}', keeping as string") + _logger.warning( + _logger.warning("Unknown download state '%s', keeping as string", self.state) + ) # Validate progress is in range - if not 0 <= self.progress <= 100: - _logger.debug(f"Progress {self.progress} out of range, clamping to [0, 100]") - object.__setattr__(self, 'progress', max(0, min(100, self.progress))) + if not _MIN_PROGRESS_PERCENT <= self.progress <= _MAX_PROGRESS_PERCENT: + _logger.debug( + _logger.debug("Progress %s out of range, clamping to [0, 100]", self.progress) + ) + object.__setattr__(self, "progress", max(0, min(100, self.progress))) @property def state_value(self) -> str: @@ -157,8 +174,7 @@ class DownloadStatus: class DownloadClient(ABC): - """ - Base class for external download clients. + """Base class for external download clients. Subclasses implement protocol-specific download management: - Torrent clients: qBittorrent, Transmission, Deluge @@ -174,8 +190,7 @@ class DownloadClient(ABC): name: str def _log_error(self, method: str, e: Exception, level: str = "error") -> str: - """ - Log a client error with consistent formatting. + """Log a client error with consistent formatting. Args: method: Name of the method that failed (e.g., "get_status") @@ -184,6 +199,7 @@ class DownloadClient(ABC): Returns: Formatted error message string (for use in DownloadStatus.error()) + """ error_type = type(e).__name__ msg = f"{self.name} {method} failed ({error_type}): {e}" @@ -198,15 +214,15 @@ class DownloadClient(ABC): return f"{error_type}: {e}" - def _build_path(self, *components: str) -> Optional[str]: - """ - Safely build a file path from components. + def _build_path(self, *components: str) -> str | None: + """Safely build a file path from components. Args: *components: Path components to join (e.g., save_path, name) Returns: Normalized path string, or None if any component is empty/None. + """ # Filter out empty/None components valid = [c for c in components if c] @@ -214,9 +230,9 @@ class DownloadClient(ABC): return None # Join and normalize - return os.path.normpath(os.path.join(*valid)) + return os.path.normpath(str(Path(valid[0]).joinpath(*valid[1:]))) - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs) -> None: """Validate that subclasses define required class attributes.""" super().__init_subclass__(**kwargs) @@ -225,48 +241,46 @@ class DownloadClient(ABC): return # Validate protocol attribute - if not hasattr(cls, 'protocol') or not cls.protocol: - raise TypeError(f"{cls.__name__} must define 'protocol' class attribute") - if cls.protocol not in ('torrent', 'usenet'): - raise TypeError( - f"{cls.__name__}.protocol must be 'torrent' or 'usenet', got '{cls.protocol}'" - ) + if not hasattr(cls, "protocol") or not cls.protocol: + msg = f"{cls.__name__} must define 'protocol' class attribute" + raise TypeError(msg) + if cls.protocol not in ("torrent", "usenet"): + msg = f"{cls.__name__}.protocol must be 'torrent' or 'usenet', got '{cls.protocol}'" + raise TypeError(msg) # Validate name attribute - if not hasattr(cls, 'name') or not cls.name: - raise TypeError(f"{cls.__name__} must define 'name' class attribute") + if not hasattr(cls, "name") or not cls.name: + msg = f"{cls.__name__} must define 'name' class attribute" + raise TypeError(msg) @staticmethod @abstractmethod def is_configured() -> bool: - """ - Check if this client is configured. + """Check if this client is configured. Returns: True if required settings (URL, etc.) are present. + """ - pass @abstractmethod - def test_connection(self) -> Tuple[bool, str]: - """ - Test connectivity to the client. + def test_connection(self) -> tuple[bool, str]: + """Test connectivity to the client. Returns: Tuple of (success, message). + """ - pass @abstractmethod def add_download( self, url: str, name: str, - category: Optional[str] = None, - expected_hash: Optional[str] = None, - **kwargs: Any, + category: str | None = None, + expected_hash: str | None = None, + **kwargs: object, ) -> str: - """Add a download to the client. Args: @@ -280,26 +294,24 @@ class DownloadClient(ABC): Raises: Exception: If adding fails. + """ - pass @abstractmethod def get_status(self, download_id: str) -> DownloadStatus: - """ - Get status of a download. + """Get status of a download. Args: download_id: The ID returned by add_download() Returns: Current download status. + """ - pass @abstractmethod - def remove(self, download_id: str, delete_files: bool = False) -> bool: - """ - Remove a download from the client. + def remove(self, download_id: str, *, delete_files: bool = False) -> bool: + """Remove a download from the client. Args: download_id: The ID returned by add_download() @@ -307,27 +319,25 @@ class DownloadClient(ABC): Returns: True if removal succeeded. + """ - pass @abstractmethod - def get_download_path(self, download_id: str) -> Optional[str]: - """ - Get the path where files were downloaded. + def get_download_path(self, download_id: str) -> str | None: + """Get the path where files were downloaded. Args: download_id: The ID returned by add_download() Returns: File or directory path, or None if not available. + """ - pass def find_existing( - self, url: str, category: Optional[str] = None - ) -> Optional[Tuple[str, DownloadStatus]]: - """ - Check if a download for this URL already exists in the client. + self, url: str, category: str | None = None + ) -> tuple[str, DownloadStatus] | None: + """Check if a download for this URL already exists in the client. This is useful for detecting already-completed downloads so we can skip re-downloading and just copy the existing file. @@ -339,17 +349,19 @@ class DownloadClient(ABC): Returns: Tuple of (download_id, status) if found, None if not found. Default implementation returns None. + """ return None # Client registry: protocol -> list of client classes -_CLIENTS: Dict[str, List[Type[DownloadClient]]] = {} +_CLIENTS: dict[str, list[type[DownloadClient]]] = {} -def register_client(protocol: str): - """ - Decorator to register a download client for a protocol. +def register_client( + protocol: str, +) -> Callable[[type[DownloadClient]], type[DownloadClient]]: + """Decorator to register a download client for a protocol. Multiple clients can be registered for the same protocol. The `is_configured()` method determines which one is active. @@ -361,9 +373,10 @@ def register_client(protocol: str): @register_client("torrent") class QBittorrentClient(DownloadClient): ... + """ - def decorator(cls: Type[DownloadClient]) -> Type[DownloadClient]: + def decorator(cls: type[DownloadClient]) -> type[DownloadClient]: if protocol not in _CLIENTS: _CLIENTS[protocol] = [] _CLIENTS[protocol].append(cls) @@ -372,9 +385,8 @@ def register_client(protocol: str): return decorator -def get_client(protocol: str) -> Optional[DownloadClient]: - """ - Get a configured client instance for the given protocol. +def get_client(protocol: str) -> DownloadClient | None: + """Get a configured client instance for the given protocol. Iterates through all registered clients for the protocol and returns the first one that is configured. @@ -384,6 +396,7 @@ def get_client(protocol: str) -> Optional[DownloadClient]: Returns: Configured client instance, or None if not available/configured. + """ if protocol not in _CLIENTS: return None @@ -395,12 +408,12 @@ def get_client(protocol: str) -> Optional[DownloadClient]: return None -def list_configured_clients() -> List[str]: - """ - List protocols that have configured clients. +def list_configured_clients() -> list[str]: + """List protocols that have configured clients. Returns: List of protocol names (e.g., ["torrent", "usenet"]). + """ result = [] for protocol, client_classes in _CLIENTS.items(): @@ -411,21 +424,21 @@ def list_configured_clients() -> List[str]: return result -def get_all_clients() -> Dict[str, List[Type[DownloadClient]]]: - """ - Get all registered client classes. +def get_all_clients() -> dict[str, list[type[DownloadClient]]]: + """Get all registered client classes. Returns: Dict of protocol -> list of client classes. + """ return dict(_CLIENTS) # Import client implementations to trigger registration # These imports are at the bottom to avoid circular imports -from shelfmark.download.clients import qbittorrent # noqa: F401, E402 -from shelfmark.download.clients import nzbget # noqa: F401, E402 -from shelfmark.download.clients import sabnzbd # noqa: F401, E402 -from shelfmark.download.clients import transmission # noqa: F401, E402 -from shelfmark.download.clients import deluge # noqa: F401, E402 -from shelfmark.download.clients import rtorrent # noqa: F401, E402 +from shelfmark.download.clients import deluge as deluge +from shelfmark.download.clients import nzbget as nzbget +from shelfmark.download.clients import qbittorrent as qbittorrent +from shelfmark.download.clients import rtorrent as rtorrent +from shelfmark.download.clients import sabnzbd as sabnzbd +from shelfmark.download.clients import transmission as transmission diff --git a/shelfmark/download/clients/base_handler.py b/shelfmark/download/clients/base_handler.py index 193da1da..efc75149 100644 --- a/shelfmark/download/clients/base_handler.py +++ b/shelfmark/download/clients/base_handler.py @@ -5,22 +5,27 @@ import time from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path -from threading import Event -from typing import Callable, Optional +from typing import TYPE_CHECKING from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.core.utils import is_audiobook from shelfmark.download.clients import ( DownloadClient, DownloadState, + DownloadStatus, get_client, list_configured_clients, ) from shelfmark.download.fs import run_blocking_io from shelfmark.release_sources import DownloadHandler +if TYPE_CHECKING: + from collections.abc import Callable + from threading import Event + + from shelfmark.core.models import DownloadTask + logger = setup_logger(__name__) # How often to poll the download client for status (seconds) @@ -37,23 +42,23 @@ class DownloadRequest: url: str protocol: str release_name: str - expected_hash: Optional[str] - seeding_time_limit: Optional[int] = None # minutes - ratio_limit: Optional[float] = None + expected_hash: str | None + seeding_time_limit: int | None = None # minutes + ratio_limit: float | None = None def _diagnose_path_issue(path: str) -> str: - """ - Analyze a path and return diagnostic hints for common issues. + """Analyze a path and return diagnostic hints for common issues. Args: path: The path that failed to be accessed Returns: A hint string to help users diagnose the issue. + """ # Detect Windows-style paths (won't work in Linux containers) - if len(path) >= 2 and path[1] == ':': + if len(path) >= 2 and path[1] == ":": return ( f"Path '{path}' appears to be a Windows path. " f"Shelfmark runs in Linux and cannot access Windows paths directly. " @@ -79,7 +84,7 @@ def _diagnose_path_issue(path: str) -> str: class ExternalClientHandler(DownloadHandler, ABC): """Shared lifecycle handler for sources that hand off to torrent/usenet clients.""" - def __init__(self): + def __init__(self) -> None: # Track downloads that may need client-side cleanup after Shelfmark completes import. # task_id -> (client, download_id, protocol) self._cleanup_refs: dict[str, tuple[DownloadClient, str, str]] = {} @@ -88,15 +93,15 @@ class ExternalClientHandler(DownloadHandler, ABC): def _resolve_download( self, task: DownloadTask, - status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[DownloadRequest]: + status_callback: Callable[[str, str | None], None], + ) -> DownloadRequest | None: """Resolve source-specific task metadata into a client download request.""" def _on_download_complete(self, task: DownloadTask) -> None: """Hook called after successful completion; override for source cleanup.""" return - def _get_client(self, protocol: str) -> Optional[DownloadClient]: + def _get_client(self, protocol: str) -> DownloadClient | None: """Resolve the active client for a protocol.""" return get_client(protocol) @@ -116,7 +121,7 @@ class ExternalClientHandler(DownloadHandler, ABC): """Maximum attempts when waiting for completed files.""" return COMPLETED_PATH_MAX_ATTEMPTS - def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> Optional[str]: + def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> str | None: """Get audiobook category if configured and applicable, else None for default.""" if not is_audiobook(task.content_type): return None @@ -132,7 +137,7 @@ class ExternalClientHandler(DownloadHandler, ABC): audiobook_key = audiobook_keys.get(client.name) return config.get(audiobook_key, "") or None if audiobook_key else None - def post_process_cleanup(self, task: DownloadTask, success: bool) -> None: + def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None: if not success: self._cleanup_refs.pop(task.task_id, None) return @@ -152,7 +157,10 @@ class ExternalClientHandler(DownloadHandler, ABC): self._remove_usenet_download(client, download_id, delete_files=True, archive=True) except Exception as e: logger.warning( - f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}" + "Failed to cleanup usenet download %s in %s: %s", + download_id, + getattr(client, "name", "client"), + e, ) elif protocol == "torrent": @@ -162,7 +170,10 @@ class ExternalClientHandler(DownloadHandler, ABC): client.remove(download_id, delete_files=False) except Exception as e: logger.warning( - f"Failed to remove torrent {download_id} from {getattr(client, 'name', 'client')}: {e}" + "Failed to remove torrent %s from %s: %s", + download_id, + getattr(client, "name", "client"), + e, ) def _remove_usenet_download( @@ -184,11 +195,13 @@ class ExternalClientHandler(DownloadHandler, ABC): try: raw_path = client.get_download_path(download_id) except Exception as e: - logger.debug(f"Failed to resolve download path for {client.name} {download_id}: {e}") + logger.debug( + "Failed to resolve download path for %s %s: %s", client.name, download_id, e + ) return if not raw_path: - logger.debug(f"No download path available for {client.name} {download_id}") + logger.debug("No download path available for %s %s", client.name, download_id) return from shelfmark.core.path_mappings import ( @@ -210,11 +223,16 @@ class ExternalClientHandler(DownloadHandler, ABC): delete_path = remapped if matched_mapping else source_path_obj if str(delete_path) in ("", "/"): - logger.warning(f"Refusing to delete unsafe path for {client.name} {download_id}: {delete_path}") + logger.warning( + "Refusing to delete unsafe path for %s %s: %s", + client.name, + download_id, + delete_path, + ) return if not run_blocking_io(delete_path.exists): - logger.debug(f"Local download path does not exist for cleanup: {delete_path}") + logger.debug("Local download path does not exist for cleanup: %s", delete_path) return try: @@ -222,18 +240,27 @@ class ExternalClientHandler(DownloadHandler, ABC): run_blocking_io(shutil.rmtree, delete_path) else: run_blocking_io(delete_path.unlink) - logger.info(f"Deleted local download data for {client.name} {download_id}: {delete_path}") + logger.info( + "Deleted local download data for %s %s: %s", client.name, download_id, delete_path + ) except Exception as e: - logger.warning(f"Failed to delete local download data for {client.name} {download_id}: {e}") + logger.warning( + "Failed to delete local download data for %s %s: %s", client.name, download_id, e + ) - def _safe_remove_download(self, client, download_id: str, protocol: str, reason: str) -> None: + def _safe_remove_download( + self, + client: DownloadClient, + download_id: str, + protocol: str, + reason: str, + ) -> None: """Best-effort removal of a failed/cancelled download from the client. Safety policy: - torrents: never remove or delete client data (avoid breaking seeding) - usenet: keep legacy behavior (delete client files on removal) """ - if protocol != "usenet": logger.info( "Skipping download client cleanup for protocol=%s after %s (client=%s id=%s)", @@ -250,7 +277,11 @@ class ExternalClientHandler(DownloadHandler, ABC): self._remove_usenet_download(client, download_id, delete_files=True, archive=False) except Exception as e: logger.warning( - f"Failed to remove download {download_id} from {client.name} after {reason}: {e}" + "Failed to remove download %s from %s after %s: %s", + download_id, + client.name, + reason, + e, ) def _handle_cancelled_download( @@ -258,20 +289,26 @@ class ExternalClientHandler(DownloadHandler, ABC): client: DownloadClient, download_id: str, protocol: str, - status_callback: Callable[[str, Optional[str]], None], + status_callback: Callable[[str, str | None], None], ) -> None: if protocol == "usenet": - logger.info(f"Download cancelled, removing from {client.name}: {download_id}") + logger.info("Download cancelled, removing from %s: %s", client.name, download_id) try: self._delete_local_download_data(client, download_id) self._remove_usenet_download(client, download_id, delete_files=True, archive=True) except Exception as e: logger.warning( - f"Failed to remove download {download_id} from {client.name} after cancellation: {e}" + "Failed to remove download %s from %s after cancellation: %s", + download_id, + client.name, + e, ) else: logger.info( - f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}" + "Download cancelled for protocol=%s; leaving in %s: %s", + protocol, + client.name, + download_id, ) status_callback("cancelled", "Cancelled") @@ -281,7 +318,7 @@ class ExternalClientHandler(DownloadHandler, ABC): download_id: str, *, log_details: bool, - ) -> tuple[Optional[Path], Optional[str]]: + ) -> tuple[Path | None, str | None]: """Resolve and validate the completed download path once.""" try: raw_path = client.get_download_path(download_id) @@ -291,12 +328,12 @@ class ExternalClientHandler(DownloadHandler, ABC): f"Check volume mappings and category settings." ) if log_details: - logger.error( - f"Failed to resolve download path for {client.name} {download_id}: {e}" + logger.exception( + "Failed to resolve download path for %s %s", client.name, download_id ) else: logger.debug( - f"Failed to resolve download path for {client.name} {download_id}: {e}" + "Failed to resolve download path for %s %s: %s", client.name, download_id, e ) return None, message @@ -306,9 +343,13 @@ class ExternalClientHandler(DownloadHandler, ABC): f"Check volume mappings and category settings." ) if log_details: - logger.error(f"Download client returned empty path for {client.name} {download_id}") + logger.error( + "Download client returned empty path for %s %s", client.name, download_id + ) else: - logger.debug(f"Download client returned empty path for {client.name} {download_id}") + logger.debug( + "Download client returned empty path for %s %s", client.name, download_id + ) return None, message from shelfmark.core.path_mappings import ( @@ -365,13 +406,19 @@ class ExternalClientHandler(DownloadHandler, ABC): ) if log_details: logger.error( - f"Download path does not exist after remapping: {raw_path} -> {remapped}. " - f"Client: {client.name}, ID: {download_id}." + "Download path does not exist after remapping: %s -> %s. Client: %s, ID: %s.", + raw_path, + remapped, + client.name, + download_id, ) else: logger.debug( - f"Download path does not exist after remapping: {raw_path} -> {remapped}. " - f"Client: {client.name}, ID: {download_id}." + "Download path does not exist after remapping: %s -> %s. Client: %s, ID: %s.", + raw_path, + remapped, + client.name, + download_id, ) return None, message @@ -389,13 +436,19 @@ class ExternalClientHandler(DownloadHandler, ABC): message = f"{hint} No remote path mapping matched for client '{client.name}'." if log_details: logger.error( - f"Download path does not exist and no remote path mapping matched for {client.name} " - f"({download_id}): {raw_path}. {hint}" + "Download path does not exist and no remote path mapping matched for %s (%s): %s. %s", + client.name, + download_id, + raw_path, + hint, ) else: logger.debug( - f"Download path does not exist and no remote path mapping matched for {client.name} " - f"({download_id}): {raw_path}. {hint}" + "Download path does not exist and no remote path mapping matched for %s (%s): %s. %s", + client.name, + download_id, + raw_path, + hint, ) return None, message @@ -404,13 +457,19 @@ class ExternalClientHandler(DownloadHandler, ABC): message = hint if log_details: logger.error( - f"Download path does not exist: {raw_path}. " - f"Client: {client.name}, ID: {download_id}. {hint}" + "Download path does not exist: %s. Client: %s, ID: %s. %s", + raw_path, + client.name, + download_id, + hint, ) else: logger.debug( - f"Download path does not exist: {raw_path}. " - f"Client: {client.name}, ID: {download_id}. {hint}" + "Download path does not exist: %s. Client: %s, ID: %s. %s", + raw_path, + client.name, + download_id, + hint, ) return None, message @@ -421,11 +480,11 @@ class ExternalClientHandler(DownloadHandler, ABC): client: DownloadClient, download_id: str, *, - cancel_flag: Optional[Event], - status_callback: Callable[[str, Optional[str]], None], - ) -> tuple[Optional[Path], Optional[str]]: + cancel_flag: Event | None, + status_callback: Callable[[str, str | None], None], + ) -> tuple[Path | None, str | None]: """Wait briefly for completed files to appear on disk.""" - last_error: Optional[str] = None + last_error: str | None = None max_attempts = self._completed_path_max_attempts() retry_interval = self._completed_path_retry_interval() @@ -462,7 +521,7 @@ class ExternalClientHandler(DownloadHandler, ABC): return None, last_error - def _build_progress_message(self, status) -> str: + def _build_progress_message(self, status: DownloadStatus) -> str: """Build a progress message from download status.""" msg = f"{status.progress:.0f}%" @@ -485,8 +544,8 @@ class ExternalClientHandler(DownloadHandler, ABC): task: DownloadTask, cancel_flag: Event, progress_callback: Callable[[float], None], - status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[str]: + status_callback: Callable[[str, str | None], None], + ) -> str | None: """Execute download via configured torrent/usenet client. Returns file path or None.""" try: if cancel_flag.is_set(): @@ -516,7 +575,7 @@ class ExternalClientHandler(DownloadHandler, ABC): if existing: download_id, existing_status = existing - logger.info(f"Found existing download in {client.name}: {download_id}") + logger.info("Found existing download in %s: %s", client.name, download_id) # If already complete, skip straight to file handling if existing_status.complete: @@ -567,11 +626,13 @@ class ExternalClientHandler(DownloadHandler, ABC): ratio_limit=request.ratio_limit, ) except Exception as e: - logger.error(f"Failed to add to {client.name}: {e}") + logger.exception("Failed to add to %s", client.name) status_callback("error", f"Failed to add to {client.name}: {e}") return None - logger.info(f"Added to {client.name}: {download_id} for '{request.release_name}'") + logger.info( + "Added to %s: %s for '%s'", client.name, download_id, request.release_name + ) # Poll for progress return self._poll_and_complete( @@ -585,7 +646,7 @@ class ExternalClientHandler(DownloadHandler, ABC): ) except Exception as e: - logger.error(f"External client download error: {e}") + logger.exception("External client download error") status_callback("error", str(e)) return None @@ -597,8 +658,8 @@ class ExternalClientHandler(DownloadHandler, ABC): task: DownloadTask, cancel_flag: Event, progress_callback: Callable[[float], None], - status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[str]: + status_callback: Callable[[str, str | None], None], + ) -> str | None: """Poll the download client for progress and handle completion.""" poll_interval = self._poll_interval() # Track consecutive "not found" errors - torrents may take time to appear in client @@ -606,7 +667,8 @@ class ExternalClientHandler(DownloadHandler, ABC): max_not_found_retries = 15 # 15 retries * poll interval ~= 30s grace period try: - logger.debug(f"Starting poll for {download_id} (content_type={task.content_type})") + result: str | None = None + logger.debug("Starting poll for %s (content_type=%s)", download_id, task.content_type) while not cancel_flag.is_set(): status = client.get_status(download_id) progress_callback(status.progress) @@ -614,12 +676,18 @@ class ExternalClientHandler(DownloadHandler, ABC): # Check for completion if status.complete: if status.state == DownloadState.ERROR: - logger.error(f"Download {download_id} completed with error: {status.message}") + logger.error( + "Download %s completed with error: %s", download_id, status.message + ) status_callback("error", status.message or "Download failed") - self._safe_remove_download(client, download_id, protocol, "completion error") + self._safe_remove_download( + client, download_id, protocol, "completion error" + ) return None # Download complete - break to handle file - logger.debug(f"Download {download_id} complete, file_path={status.file_path}") + logger.debug( + "Download %s complete, file_path=%s", download_id, status.file_path + ) break # Check for error state @@ -653,8 +721,10 @@ class ExternalClientHandler(DownloadHandler, ABC): not_found_count += 1 if not_found_count < max_not_found_retries: logger.debug( - f"Download {download_id} not yet visible in client " - f"(attempt {not_found_count}/{max_not_found_retries})" + "Download %s not yet visible in client (attempt %s/%s)", + download_id, + not_found_count, + max_not_found_retries, ) status_callback("resolving", "Waiting for download client...") if cancel_flag.wait(timeout=poll_interval): @@ -662,11 +732,13 @@ class ExternalClientHandler(DownloadHandler, ABC): continue logger.error( - f"Download {download_id} not found after {max_not_found_retries} attempts" + "Download %s not found after %s attempts", + download_id, + max_not_found_retries, ) else: # Fail fast on actionable errors (auth, connectivity, API issues) - logger.error(f"Download {download_id} error state: {status.message}") + logger.error("Download %s error state: %s", download_id, status.message) status_callback("error", status.message or "Download failed") self._safe_remove_download(client, download_id, protocol, "download error") @@ -725,18 +797,20 @@ class ExternalClientHandler(DownloadHandler, ABC): return result except Exception as e: - logger.error(f"Error during download polling: {e}") + logger.exception("Error during download polling") status_callback("error", str(e)) self._safe_remove_download(client, download_id, protocol, "polling exception") return None + else: + return result def _handle_completed_file( self, source_path: Path, protocol: str, task: DownloadTask, - status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[str]: + status_callback: Callable[[str, str | None], None], + ) -> str | None: """Handle a completed download and return its path. For external download clients (torrents/usenet), staging large payloads into TMP_DIR @@ -751,15 +825,15 @@ class ExternalClientHandler(DownloadHandler, ABC): if protocol == "torrent": task.original_download_path = str(source_path) - logger.debug(f"Download complete, returning original path: {source_path}") + logger.debug("Download complete, returning original path: %s", source_path) return str(source_path) except Exception as e: - logger.error(f"Failed to finalize completed download at {source_path}: {e}") + logger.exception("Failed to finalize completed download at %s", source_path) status_callback("error", f"Failed to finalize completed download: {e}") return None def cancel(self, task_id: str) -> bool: """Default cancellation (primary cancellation happens via cancel_flag).""" - logger.debug(f"Cancel requested for external client task: {task_id}") + logger.debug("Cancel requested for external client task: %s", task_id) return True diff --git a/shelfmark/download/clients/deluge.py b/shelfmark/download/clients/deluge.py index 54589614..690992f0 100644 --- a/shelfmark/download/clients/deluge.py +++ b/shelfmark/download/clients/deluge.py @@ -12,13 +12,13 @@ Requirements: """ import base64 -from typing import Any, Optional, Tuple +from contextlib import suppress +from typing import Any, NoReturn from urllib.parse import urlparse import requests from shelfmark.core.config import config -from shelfmark.download.network import get_ssl_verify from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url from shelfmark.download.clients import ( @@ -29,22 +29,32 @@ from shelfmark.download.clients import ( from shelfmark.download.clients.torrent_utils import ( extract_torrent_info, ) +from shelfmark.download.network import get_ssl_verify logger = setup_logger(__name__) +MIN_DAEMON_HOST_ENTRY_LENGTH = 2 +MIN_DAEMON_HOST_STATUS_ENTRY_LENGTH = 4 +DOWNLOAD_COMPLETE_PROGRESS = 100 +ONE_WEEK_IN_SECONDS = 604800 + class DelugeRpcError(RuntimeError): - def __init__(self, message: str, code: int | None = None): + def __init__(self, message: str, code: int | None = None) -> None: super().__init__(message) self.code = code -def _get_error_message(error: Any) -> Tuple[str, int | None]: +def _get_error_message(error: object) -> tuple[str, int | None]: if isinstance(error, dict): return str(error.get("message") or error), error.get("code") return str(error), None +def _raise_runtime_error(message: str) -> NoReturn: + raise RuntimeError(message) + + @register_client("torrent") class DelugeClient(DownloadClient): """Deluge download client using Deluge Web UI JSON-RPC.""" @@ -52,15 +62,17 @@ class DelugeClient(DownloadClient): protocol = "torrent" name = "deluge" - def __init__(self): + def __init__(self) -> None: raw_host = str(config.get("DELUGE_HOST", "localhost") or "") raw_port = str(config.get("DELUGE_PORT", "8112") or "8112") password = str(config.get("DELUGE_PASSWORD", "") or "") if not raw_host: - raise ValueError("DELUGE_HOST is required") + msg = "DELUGE_HOST is required" + raise ValueError(msg) if not password: - raise ValueError("DELUGE_PASSWORD is required") + msg = "DELUGE_PASSWORD is required" + raise ValueError(msg) scheme = "http" base_path = "" @@ -69,7 +81,8 @@ class DelugeClient(DownloadClient): # (useful when Deluge is behind a reverse proxy path). raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else "" if not raw_host: - raise ValueError("DELUGE_HOST is invalid") + msg = "DELUGE_HOST is invalid" + raise ValueError(msg) host = raw_host port = int(raw_port) @@ -81,13 +94,12 @@ class DelugeClient(DownloadClient): if parsed.port is not None: port = parsed.port base_path = (parsed.path or "").rstrip("/") - else: - # Allow "host:port" in DELUGE_HOST for convenience. - if ":" in raw_host and raw_host.count(":") == 1: - host_part, port_part = raw_host.split(":", 1) - if host_part and port_part.isdigit(): - host = host_part - port = int(port_part) + # Allow "host:port" in DELUGE_HOST for convenience. + elif ":" in raw_host and raw_host.count(":") == 1: + host_part, port_part = raw_host.split(":", 1) + if host_part and port_part.isdigit(): + host = host_part + port = int(port_part) self._rpc_url = f"{scheme}://{host}:{port}{base_path}/json" self._password = password @@ -104,14 +116,19 @@ class DelugeClient(DownloadClient): self._rpc_id += 1 return self._rpc_id - def _rpc_call(self, method: str, *params: Any, timeout: int = 15) -> Any: + def _rpc_call(self, method: str, *params: object, timeout: int = 15) -> object: payload = { "id": self._next_rpc_id(), "method": method, "params": list(params), } - response = self._session.post(self._rpc_url, json=payload, timeout=timeout, verify=get_ssl_verify(self._rpc_url)) + response = self._session.post( + self._rpc_url, + json=payload, + timeout=timeout, + verify=get_ssl_verify(self._rpc_url), + ) response.raise_for_status() data = response.json() @@ -124,7 +141,8 @@ class DelugeClient(DownloadClient): def _login(self) -> None: result = self._rpc_call("auth.login", self._password) if result is not True: - raise DelugeRpcError("Deluge Web UI authentication failed") + msg = "Deluge Web UI authentication failed" + raise DelugeRpcError(msg) self._authenticated = True def _select_daemon_host_id(self, hosts: list) -> str: @@ -133,11 +151,19 @@ class DelugeClient(DownloadClient): preferred_hosts = {"127.0.0.1", "localhost"} for entry in hosts: - if isinstance(entry, list) and len(entry) >= 2 and entry[1] in preferred_hosts: + if ( + isinstance(entry, list) + and len(entry) >= MIN_DAEMON_HOST_ENTRY_LENGTH + and entry[1] in preferred_hosts + ): return str(entry[0]) for entry in hosts: - if isinstance(entry, list) and len(entry) >= 4 and str(entry[3]).lower() == "online": + if ( + isinstance(entry, list) + and len(entry) >= MIN_DAEMON_HOST_STATUS_ENTRY_LENGTH + and str(entry[3]).lower() == "online" + ): return str(entry[0]) return str(hosts[0][0]) @@ -155,23 +181,25 @@ class DelugeClient(DownloadClient): hosts = self._rpc_call("web.get_hosts") or [] if not hosts: - raise DelugeRpcError( + msg = ( "Deluge Web UI isn't connected to Deluge core (no hosts configured). " "Add/connect a daemon in Deluge Web UI → Connection Manager." ) + raise DelugeRpcError(msg) host_id = self._select_daemon_host_id(hosts) self._rpc_call("web.connect", host_id) if self._rpc_call("web.connected") is not True: - raise DelugeRpcError( + msg = ( "Deluge Web UI couldn't connect to Deluge core. " "Check daemon status in Deluge Web UI → Connection Manager." ) + raise DelugeRpcError(msg) self._connected = True - def _get_daemon_version(self) -> Any: + def _get_daemon_version(self) -> object: """Fetch daemon version, preferring daemon.get_version when available.""" try: methods = self._rpc_call("system.listMethods") @@ -190,14 +218,12 @@ class DelugeClient(DownloadClient): try: # label.add will error if the plugin is unavailable or the label exists. - try: + with suppress(Exception): self._rpc_call("label.add", label) - except Exception: - pass self._rpc_call("label.set_torrent", torrent_id, label) except Exception as e: - logger.debug(f"Could not set Deluge label '{label}' for {torrent_id}: {e}") + logger.debug("Could not set Deluge label '%s' for %s: %s", label, torrent_id, e) @staticmethod def is_configured() -> bool: @@ -206,22 +232,23 @@ class DelugeClient(DownloadClient): password = config.get("DELUGE_PASSWORD", "") return client == "deluge" and bool(host) and bool(password) - def test_connection(self) -> Tuple[bool, str]: + def test_connection(self) -> tuple[bool, str]: try: self._ensure_connected() version = self._get_daemon_version() - return True, f"Connected to Deluge {version}" except Exception as e: self._authenticated = False self._connected = False - return False, f"Connection failed: {str(e)}" + return False, f"Connection failed: {e!s}" + else: + return True, f"Connected to Deluge {version}" def add_download( self, url: str, name: str, - category: Optional[str] = None, - expected_hash: Optional[str] = None, + category: str | None = None, + expected_hash: str | None = None, **kwargs, ) -> str: try: @@ -231,7 +258,7 @@ class DelugeClient(DownloadClient): torrent_info = extract_torrent_info(url, expected_hash=expected_hash) if not torrent_info.is_magnet and not torrent_info.torrent_data: - raise Exception("Failed to fetch torrent file") + _raise_runtime_error("Failed to fetch torrent file") options: dict[str, Any] = {} if self._download_dir: @@ -252,7 +279,7 @@ class DelugeClient(DownloadClient): else: torrent_data = torrent_info.torrent_data if torrent_data is None: - raise Exception("Failed to fetch torrent file") + _raise_runtime_error("Failed to fetch torrent file") torrent_data_bytes: bytes = torrent_data filedump = base64.b64encode(torrent_data_bytes).decode("ascii") @@ -264,19 +291,20 @@ class DelugeClient(DownloadClient): ) if not torrent_id: - raise Exception("Deluge returned no torrent ID") + _raise_runtime_error("Deluge returned no torrent ID") torrent_id = str(torrent_id).lower() self._try_set_label(torrent_id, category_value) - logger.info(f"Added torrent to Deluge: {torrent_id}") - return torrent_id + logger.info("Added torrent to Deluge: %s", torrent_id) - except Exception as e: + except Exception: self._authenticated = False self._connected = False - logger.error(f"Deluge add failed: {e}") + logger.exception("Deluge add failed") raise + else: + return torrent_id def get_status(self, download_id: str) -> DownloadStatus: try: @@ -285,7 +313,14 @@ class DelugeClient(DownloadClient): status = self._rpc_call( "core.get_torrent_status", download_id, - ["state", "progress", "download_payload_rate", "eta", "save_path", "name"], + [ + "state", + "progress", + "download_payload_rate", + "eta", + "save_path", + "name", + ], ) if not status: @@ -308,7 +343,7 @@ class DelugeClient(DownloadClient): progress = float(status.get("progress", 0)) # Don't mark complete while files are being moved - complete = progress >= 100 and deluge_state != "Moving" + complete = progress >= DOWNLOAD_COMPLETE_PROGRESS and deluge_state != "Moving" if complete: message = "Complete" @@ -320,7 +355,7 @@ class DelugeClient(DownloadClient): except Exception: eta = None - if eta is not None and (eta < 0 or eta > 604800): + if eta is not None and (eta < 0 or eta > ONE_WEEK_IN_SECONDS): eta = None file_path = None @@ -344,24 +379,26 @@ class DelugeClient(DownloadClient): except Exception as e: return DownloadStatus.error(self._log_error("get_status", e)) - def remove(self, download_id: str, delete_files: bool = False) -> bool: + def remove(self, download_id: str, *, delete_files: bool = False) -> bool: try: self._ensure_connected() result = self._rpc_call("core.remove_torrent", download_id, delete_files) if result: logger.info( - f"Removed torrent from Deluge: {download_id}" - + (" (with files)" if delete_files else "") + "Removed torrent from Deluge: %s%s", + download_id, + " (with files)" if delete_files else "", ) return True - return False except Exception as e: self._log_error("remove", e) return False + else: + return False - def get_download_path(self, download_id: str) -> Optional[str]: + def get_download_path(self, download_id: str) -> str | None: try: self._ensure_connected() @@ -376,15 +413,16 @@ class DelugeClient(DownloadClient): str(status.get("save_path", "")), str(status.get("name", "")), ) - return None except Exception as e: self._log_error("get_download_path", e, level="debug") return None + else: + return None def find_existing( - self, url: str, category: Optional[str] = None - ) -> Optional[Tuple[str, DownloadStatus]]: + self, url: str, category: str | None = None + ) -> tuple[str, DownloadStatus] | None: try: self._ensure_connected() @@ -402,10 +440,10 @@ class DelugeClient(DownloadClient): full_status = self.get_status(torrent_info.info_hash) return (torrent_info.info_hash, full_status) - return None - except Exception as e: self._authenticated = False self._connected = False - logger.debug(f"Error checking for existing torrent: {e}") + logger.debug("Error checking for existing torrent: %s", e) + return None + else: return None diff --git a/shelfmark/download/clients/nzbget.py b/shelfmark/download/clients/nzbget.py index 54a45283..ab56c524 100644 --- a/shelfmark/download/clients/nzbget.py +++ b/shelfmark/download/clients/nzbget.py @@ -1,24 +1,22 @@ -""" -NZBGet download client for Prowlarr integration. +"""NZBGet download client for Prowlarr integration. Uses NZBGet's JSON-RPC API directly via requests (no external dependency). """ import json -from typing import Any, Optional, Tuple import requests from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url -from shelfmark.download.network import get_ssl_verify from shelfmark.download.clients import ( DownloadClient, DownloadStatus, register_client, with_retry, ) +from shelfmark.download.network import get_ssl_verify logger = setup_logger(__name__) @@ -30,15 +28,17 @@ class NZBGetClient(DownloadClient): protocol = "usenet" name = "nzbget" - def __init__(self): + def __init__(self) -> None: """Initialize NZBGet client with settings from config.""" raw_url = config.get("NZBGET_URL", "") if not raw_url: - raise ValueError("NZBGET_URL is required") + msg = "NZBGET_URL is required" + raise ValueError(msg) self.url = normalize_http_url(raw_url) if not self.url: - raise ValueError("NZBGET_URL is invalid") + msg = "NZBGET_URL is invalid" + raise ValueError(msg) self.username = config.get("NZBGET_USERNAME", "nzbget") self.password = config.get("NZBGET_PASSWORD", "") self._category = config.get("NZBGET_CATEGORY", "Books") @@ -50,10 +50,22 @@ class NZBGetClient(DownloadClient): url = normalize_http_url(config.get("NZBGET_URL", "")) return client == "nzbget" and bool(url) + def _try_remove_command( + self, command: str, nzb_id: int, download_id: str + ) -> tuple[bool, Exception | None]: + """Try one NZBGet delete command and return any error.""" + try: + result = self._rpc_call("editqueue", [command, 0, "", nzb_id]) + if result: + logger.info("Removed NZB from NZBGet (%s): %s", command, download_id) + return True, None + except Exception as e: + return False, e + return False, None + @with_retry() - def _rpc_call(self, method: str, params: Optional[list] = None) -> Any: - """ - Make a JSON-RPC call to NZBGet. + def _rpc_call(self, method: str, params: list | None = None) -> object: + """Make a JSON-RPC call to NZBGet. Args: method: RPC method name @@ -64,15 +76,19 @@ class NZBGetClient(DownloadClient): Raises: Exception: If RPC call fails after retries. + """ rpc_url = f"{self.url}/jsonrpc" - payload = json.dumps({ - "jsonrpc": "2.0", - "id": 1, - "method": method, - "params": params or [], - }, separators=(',', ':')) + payload = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params or [], + }, + separators=(",", ":"), + ) response = requests.post( rpc_url, @@ -85,34 +101,34 @@ class NZBGetClient(DownloadClient): response.raise_for_status() result = response.json() - if "error" in result and result["error"]: - raise Exception(result["error"].get("message", "RPC error")) + if result.get("error"): + raise RuntimeError(result["error"].get("message", "RPC error")) return result.get("result") - def test_connection(self) -> Tuple[bool, str]: + def test_connection(self) -> tuple[bool, str]: """Test connection to NZBGet.""" try: status = self._rpc_call("status") version = status.get("Version", "unknown") - return True, f"Connected to NZBGet {version}" except requests.exceptions.ConnectionError: return False, "Could not connect to NZBGet" except requests.exceptions.Timeout: return False, "Connection timed out" except Exception as e: - return False, f"Connection failed: {str(e)}" + return False, f"Connection failed: {e!s}" + else: + return True, f"Connected to NZBGet {version}" def add_download( self, url: str, name: str, - category: Optional[str] = None, - expected_hash: Optional[str] = None, + category: str | None = None, + expected_hash: str | None = None, **kwargs, ) -> str: - """ - Add NZB by URL. + """Add NZB by URL. Fetches the NZB content from the URL (e.g., Prowlarr proxy) and sends it base64-encoded to NZBGet, since NZBGet may not handle redirects well. @@ -128,21 +144,26 @@ class NZBGetClient(DownloadClient): Raises: Exception: If adding fails. + """ import base64 # Use configured category if not explicitly provided category = category or self._category + def _raise_invalid_nzb_id() -> None: + msg = "NZBGet returned invalid ID" + raise RuntimeError(msg) + try: # Fetch NZB content from the URL (handles Prowlarr proxy redirects) - logger.debug(f"Fetching NZB from: {url}") + logger.debug("Fetching NZB from: %s", url) response = requests.get(url, timeout=30, verify=get_ssl_verify(url)) response.raise_for_status() - nzb_content = base64.b64encode(response.content).decode('ascii') + nzb_content = base64.b64encode(response.content).decode("ascii") # Ensure filename has .nzb extension - nzb_filename = name if name.endswith('.nzb') else f"{name}.nzb" + nzb_filename = name if name.endswith(".nzb") else f"{name}.nzb" # NZBGet append method parameters (all 10 required): # NZBFilename, Content, Category, Priority, AddToTop, AddPaused, @@ -164,26 +185,27 @@ class NZBGetClient(DownloadClient): ) if nzb_id and nzb_id > 0: - logger.info(f"Added NZB to NZBGet: {nzb_id}") + logger.info("Added NZB to NZBGet: %s", nzb_id) return str(nzb_id) - raise Exception("NZBGet returned invalid ID") + _raise_invalid_nzb_id() except requests.RequestException as e: - logger.error(f"Failed to fetch NZB from URL: {e}") - raise Exception(f"Failed to fetch NZB: {e}") - except Exception as e: - logger.error(f"NZBGet add failed: {e}") + logger.exception("Failed to fetch NZB from URL") + msg = f"Failed to fetch NZB: {e}" + raise RuntimeError(msg) from e + except Exception: + logger.exception("NZBGet add failed") raise def get_status(self, download_id: str) -> DownloadStatus: - """ - Get NZB status by ID. + """Get NZB status by ID. Args: download_id: NZBGet NZBID Returns: Current download status. + """ try: nzb_id = int(download_id) @@ -195,18 +217,12 @@ class NZBGetClient(DownloadClient): if group.get("NZBID") == nzb_id: # Calculate progress # NZBGet uses Hi/Lo for 64-bit values on 32-bit systems - file_size = (group.get("FileSizeHi", 0) << 32) + group.get( - "FileSizeLo", 0 - ) + file_size = (group.get("FileSizeHi", 0) << 32) + group.get("FileSizeLo", 0) remaining = (group.get("RemainingSizeHi", 0) << 32) + group.get( "RemainingSizeLo", 0 ) - progress = ( - ((file_size - remaining) / file_size * 100) - if file_size > 0 - else 0 - ) + progress = ((file_size - remaining) / file_size * 100) if file_size > 0 else 0 status = group.get("Status", "") # Map NZBGet status to our states @@ -229,9 +245,7 @@ class NZBGetClient(DownloadClient): file_path=None, download_speed=group.get("DownloadRate"), eta=( - group.get("RemainingSec") - if group.get("RemainingSec", 0) > 0 - else None + group.get("RemainingSec") if group.get("RemainingSec", 0) > 0 else None ), ) @@ -254,7 +268,6 @@ class NZBGetClient(DownloadClient): else: file_path = None - if "SUCCESS" in status: return DownloadStatus( progress=100, @@ -263,21 +276,20 @@ class NZBGetClient(DownloadClient): complete=True, file_path=file_path, ) - else: - return DownloadStatus( - progress=100, - state="error", - message=f"Download failed: {status}", - complete=True, - file_path=file_path, - ) + return DownloadStatus( + progress=100, + state="error", + message=f"Download failed: {status}", + complete=True, + file_path=file_path, + ) # Not found in queue or history return DownloadStatus.error("Download not found") except Exception as e: return DownloadStatus.error(self._log_error("get_status", e)) - def remove(self, download_id: str, delete_files: bool = False) -> bool: + def remove(self, download_id: str, *, delete_files: bool = False) -> bool: """Remove a download from NZBGet. NZBGet can remove items from either the active queue (Group* commands) or from @@ -289,6 +301,7 @@ class NZBGetClient(DownloadClient): Returns: True if successful. + """ try: nzb_id = int(download_id) @@ -303,29 +316,27 @@ class NZBGetClient(DownloadClient): else: commands = ["GroupDelete", "HistoryDelete"] - last_error: Optional[Exception] = None + last_error: Exception | None = None for command in commands: - try: - result = self._rpc_call("editqueue", [command, 0, "", nzb_id]) - if result: - logger.info(f"Removed NZB from NZBGet ({command}): {download_id}") - return True - except Exception as e: - last_error = e + success, error = self._try_remove_command(command, nzb_id, download_id) + if success: + return True + if error is not None: + last_error = error if last_error is not None: self._log_error("remove", last_error) return False - def get_download_path(self, download_id: str) -> Optional[str]: - """ - Get the path where NZB files are located. + def get_download_path(self, download_id: str) -> str | None: + """Get the path where NZB files are located. Args: download_id: NZBGet NZBID Returns: Destination directory, or None. + """ status = self.get_status(download_id) return status.file_path diff --git a/shelfmark/download/clients/qbittorrent.py b/shelfmark/download/clients/qbittorrent.py index 1fdc6502..85b9ae14 100644 --- a/shelfmark/download/clients/qbittorrent.py +++ b/shelfmark/download/clients/qbittorrent.py @@ -1,13 +1,14 @@ """qBittorrent download client for Prowlarr integration.""" import time +from http import HTTPStatus +from pathlib import Path from types import SimpleNamespace -from typing import Optional, Tuple +from typing import NoReturn from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url -from shelfmark.download.network import get_ssl_verify from shelfmark.download.clients import ( DownloadClient, DownloadStatus, @@ -16,22 +17,33 @@ from shelfmark.download.clients import ( from shelfmark.download.clients.torrent_utils import ( extract_torrent_info, ) +from shelfmark.download.network import get_ssl_verify logger = setup_logger(__name__) +_HASH_LENGTH_40 = 40 +_HASH_LENGTH_ED2K = 32 +_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN +_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND +_ONE_WEEK_IN_SECONDS = 604800 + def _hashes_match(hash1: str, hash2: str) -> bool: """Compare hashes, handling Amarr's 40-char zero-padded hashes vs 32-char ed2k hashes.""" h1, h2 = hash1.lower(), hash2.lower() if h1 == h2: return True - if len(h1) == 40 and len(h2) == 32 and h1.endswith("00000000"): - return h1[:32] == h2 - if len(h2) == 40 and len(h1) == 32 and h2.endswith("00000000"): - return h2[:32] == h1 + if len(h1) == _HASH_LENGTH_40 and len(h2) == _HASH_LENGTH_ED2K and h1.endswith("00000000"): + return h1[:_HASH_LENGTH_ED2K] == h2 + if len(h2) == _HASH_LENGTH_40 and len(h1) == _HASH_LENGTH_ED2K and h2.endswith("00000000"): + return h2[:_HASH_LENGTH_ED2K] == h1 return False +def _raise_runtime_error(message: str) -> NoReturn: + raise RuntimeError(message) + + def _normalize_tags(raw_tags: object) -> list[str]: """Normalize tag input to a clean, de-duplicated list of strings.""" if raw_tags is None: @@ -82,7 +94,7 @@ def _is_explicit_add_failure(raw_result: object) -> bool: class QBittorrentClient(DownloadClient): """qBittorrent download client.""" - def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, Optional[str]]: + def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, str | None]: """Check whether qBittorrent has registered a torrent yet. Uses `/api/v2/torrents/properties?hash=`. @@ -92,6 +104,7 @@ class QBittorrentClient(DownloadClient): Notes: A false result with no error means "not loaded yet". + """ import requests @@ -103,23 +116,24 @@ class QBittorrentClient(DownloadClient): response = self._client._session.get(url, params=params, timeout=10) # Re-authenticate and retry once on 403 - if response.status_code == 403: - logger.debug("qBittorrent returned 403 for properties; re-authenticating and retrying") + if response.status_code == _HTTP_STATUS_FORBIDDEN: + logger.debug( + "qBittorrent returned 403 for properties; re-authenticating and retrying" + ) self._client.auth_log_in() response = self._client._session.get(url, params=params, timeout=10) - if response.status_code == 403: + if response.status_code == _HTTP_STATUS_FORBIDDEN: return False, "qBittorrent authentication failed (HTTP 403)" # qBittorrent returns 404/409-ish responses depending on version when missing. - if response.status_code == 404: + if response.status_code == _HTTP_STATUS_NOT_FOUND: return False, None response.raise_for_status() - return True, None except requests.exceptions.HTTPError as e: status = getattr(getattr(e, "response", None), "status_code", None) - if status == 404: + if status == _HTTP_STATUS_NOT_FOUND: return False, None if status: return False, f"qBittorrent API request failed (HTTP {status})" @@ -130,23 +144,27 @@ class QBittorrentClient(DownloadClient): return False, f"qBittorrent request timed out at {self._base_url}" except Exception as e: return False, f"qBittorrent API error: {type(e).__name__}: {e}" + else: + return True, None protocol = "torrent" name = "qbittorrent" - def __init__(self): + def __init__(self) -> None: """Initialize qBittorrent client with settings from config.""" # Lazy import to avoid dependency issues if not using torrents from qbittorrentapi import Client raw_url = config.get("QBITTORRENT_URL", "") if not raw_url: - raise ValueError("QBITTORRENT_URL is required") + msg = "QBITTORRENT_URL is required" + raise ValueError(msg) # We use `_base_url` for direct HTTP calls, so it must be a fully-qualified URL. self._base_url = normalize_http_url(raw_url) if not self._base_url: - raise ValueError("QBITTORRENT_URL is invalid") + msg = "QBITTORRENT_URL is invalid" + raise ValueError(msg) # qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL # for consistency. @@ -160,10 +178,9 @@ class QBittorrentClient(DownloadClient): self._download_dir = config.get("QBITTORRENT_DOWNLOAD_DIR", "") self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", [])) - def _get_torrents_info( - self, torrent_hash: Optional[str] = None - ) -> tuple[list[SimpleNamespace], Optional[str]]: + self, torrent_hash: str | None = None + ) -> tuple[list[SimpleNamespace], str | None]: """Get torrent info using GET. Behaviors: @@ -174,6 +191,7 @@ class QBittorrentClient(DownloadClient): Returns: (torrents, error_message) + """ import requests @@ -188,13 +206,13 @@ class QBittorrentClient(DownloadClient): response: requests.Response, *, request_params: dict[str, str], - ) -> tuple[list[SimpleNamespace], Optional[str]]: - if response.status_code == 403: + ) -> tuple[list[SimpleNamespace], str | None]: + if response.status_code == _HTTP_STATUS_FORBIDDEN: logger.debug("qBittorrent returned 403; re-authenticating and retrying") self._client.auth_log_in() response = self._client._session.get(url, params=request_params, timeout=10) - if response.status_code == 403: + if response.status_code == _HTTP_STATUS_FORBIDDEN: logger.warning("qBittorrent authentication failed (HTTP 403)") return [], "qBittorrent authentication failed (HTTP 403)" @@ -236,24 +254,22 @@ class QBittorrentClient(DownloadClient): return all_torrents, None - return torrents, None - except requests.exceptions.HTTPError as e: status = getattr(getattr(e, "response", None), "status_code", None) if status: - logger.warning(f"qBittorrent API error (HTTP {status}): {e}") + logger.warning("qBittorrent API error (HTTP %s): %s", status, e) return [], f"qBittorrent API request failed (HTTP {status})" - logger.warning(f"qBittorrent API error: {e}") + logger.warning("qBittorrent API error: %s", e) return [], "qBittorrent API request failed" except requests.exceptions.ConnectionError: - logger.warning(f"Cannot connect to qBittorrent at {self._base_url}") + logger.warning("Cannot connect to qBittorrent at %s", self._base_url) return [], f"Cannot connect to qBittorrent at {self._base_url}" except requests.exceptions.Timeout: - logger.warning(f"qBittorrent request timed out at {self._base_url}") + logger.warning("qBittorrent request timed out at %s", self._base_url) return [], f"qBittorrent request timed out at {self._base_url}" except Exception as e: - logger.debug(f"Failed to get torrents info: {e}") + logger.debug("Failed to get torrents info: %s", e) # requests raises InvalidSchema when the base URL doesn't include http(s) if type(e).__name__ == "InvalidSchema": return ( @@ -262,6 +278,8 @@ class QBittorrentClient(DownloadClient): f"Configured: {self._base_url}", ) return [], f"qBittorrent API error: {type(e).__name__}: {e}" + else: + return torrents, None @staticmethod def is_configured() -> bool: @@ -270,14 +288,15 @@ class QBittorrentClient(DownloadClient): url = normalize_http_url(config.get("QBITTORRENT_URL", "")) return client == "qbittorrent" and bool(url) - def test_connection(self) -> Tuple[bool, str]: + def test_connection(self) -> tuple[bool, str]: """Test connection to qBittorrent.""" try: self._client.auth_log_in() api_version = self._client.app.web_api_version - return True, f"Connected to qBittorrent (API v{api_version})" except Exception as e: - return False, f"Connection failed: {str(e)}" + return False, f"Connection failed: {e!s}" + else: + return True, f"Connected to qBittorrent (API v{api_version})" def add_download( self, @@ -287,8 +306,7 @@ class QBittorrentClient(DownloadClient): expected_hash: str | None = None, **kwargs, ) -> str: - """ - Add torrent by URL (magnet or .torrent). + """Add torrent by URL (magnet or .torrent). Args: url: Magnet link or .torrent URL @@ -301,6 +319,7 @@ class QBittorrentClient(DownloadClient): Raises: Exception: If adding fails. + """ try: # Use configured category if not explicitly provided @@ -316,7 +335,10 @@ class QBittorrentClient(DownloadClient): # Log other errors but continue since download may still work if "Conflict" not in type(e).__name__ and "409" not in str(e): logger.debug( - f"Could not create category '{category}': {type(e).__name__}: {e}" + "Could not create category '%s': %s: %s", + category, + type(e).__name__, + e, ) torrent_info = extract_torrent_info(url, expected_hash=expected_hash) @@ -356,13 +378,13 @@ class QBittorrentClient(DownloadClient): ) result_text = _normalize_add_result(result) - logger.debug(f"qBittorrent add result: {result_text}") + logger.debug("qBittorrent add result: %s", result_text) if not expected_hash: - raise Exception("Could not determine torrent hash from URL") + _raise_runtime_error("Could not determine torrent hash from URL") if _is_explicit_add_failure(result): - raise Exception(f"Failed to add torrent: {result_text}") + _raise_runtime_error(f"Failed to add torrent: {result_text}") # Some qBittorrent-compatible clients return HTTP 200 with an empty body # instead of qBittorrent's literal "Ok." response. Prefer verifying that @@ -370,30 +392,31 @@ class QBittorrentClient(DownloadClient): for _ in range(10): loaded, error = self._is_torrent_loaded(expected_hash) if error: - logger.debug(f"qBittorrent add_download: {error}") + logger.debug("qBittorrent add_download: %s", error) if loaded: - logger.info(f"Added torrent: {expected_hash}") + logger.info("Added torrent: %s", expected_hash) return expected_hash.lower() time.sleep(0.5) logger.warning( - "Torrent add was not confirmed within the visibility grace period " - f"(response={result_text or ''}), returning expected hash" + "Torrent add was not confirmed within the visibility grace period (response=%s), returning expected hash", + result_text, ) - return expected_hash - except Exception as e: - logger.error(f"qBittorrent add failed: {e}") + except Exception: + logger.exception("qBittorrent add failed") raise + else: + return expected_hash def get_status(self, download_id: str) -> DownloadStatus: - """ - Get torrent status by hash. + """Get torrent status by hash. Args: download_id: Torrent info_hash Returns: Current download status. + """ try: torrents, error = self._get_torrents_info(download_id) @@ -405,7 +428,7 @@ class QBittorrentClient(DownloadClient): t for t in torrents if isinstance(getattr(t, "hash", None), str) - and _hashes_match(getattr(t, "hash"), download_id) + and _hashes_match(t.hash, download_id) ), None, ) @@ -414,7 +437,10 @@ class QBittorrentClient(DownloadClient): # Map qBittorrent states to our states and user-friendly messages state_info = { - "downloading": ("downloading", None), # None = use default progress message + "downloading": ( + "downloading", + None, + ), # None = use default progress message "stalledDL": ("downloading", "Stalled"), "metaDL": ("downloading", "Fetching metadata"), "forcedDL": ("downloading", None), @@ -449,7 +475,11 @@ class QBittorrentClient(DownloadClient): message = "Complete" torrent_eta = getattr(torrent, "eta", 0) - eta = torrent_eta if isinstance(torrent_eta, int) and 0 < torrent_eta < 604800 else None + eta = ( + torrent_eta + if isinstance(torrent_eta, int) and 0 < torrent_eta < _ONE_WEEK_IN_SECONDS + else None + ) # Get file path for completed downloads file_path = None @@ -471,9 +501,8 @@ class QBittorrentClient(DownloadClient): except Exception as e: return DownloadStatus.error(self._log_error("get_status", e)) - def remove(self, download_id: str, delete_files: bool = False) -> bool: - """ - Remove a torrent from qBittorrent. + def remove(self, download_id: str, *, delete_files: bool = False) -> bool: + """Remove a torrent from qBittorrent. Args: download_id: Torrent info_hash @@ -481,21 +510,22 @@ class QBittorrentClient(DownloadClient): Returns: True if successful. + """ try: - self._client.torrents_delete( - torrent_hashes=download_id, delete_files=delete_files - ) + self._client.torrents_delete(torrent_hashes=download_id, delete_files=delete_files) logger.info( - f"Removed torrent from qBittorrent: {download_id}" - + (" (with files)" if delete_files else "") + "Removed torrent from qBittorrent: %s%s", + download_id, + " (with files)" if delete_files else "", ) - return True except Exception as e: self._log_error("remove", e) return False + else: + return True - def get_download_path(self, download_id: str) -> Optional[str]: + def get_download_path(self, download_id: str) -> str | None: """Get the path where torrent files are located. Prefer `content_path` when available. @@ -506,12 +536,10 @@ class QBittorrentClient(DownloadClient): - `/api/v2/torrents/files?hash=` for the first file name - join `save_path` with the torrent's top-level directory """ - import os - try: torrents, error = self._get_torrents_info(download_id) if error: - logger.debug(f"qBittorrent get_download_path: {error}") + logger.debug("qBittorrent get_download_path: %s", error) return None torrent = next( @@ -519,7 +547,7 @@ class QBittorrentClient(DownloadClient): t for t in torrents if isinstance(getattr(t, "hash", None), str) - and _hashes_match(getattr(t, "hash"), download_id) + and _hashes_match(t.hash, download_id) ), None, ) @@ -531,7 +559,7 @@ class QBittorrentClient(DownloadClient): self._log_error("get_download_path", e, level="debug") return None - def _resolve_completed_download_path(self, torrent: SimpleNamespace) -> Optional[str]: + def _resolve_completed_download_path(self, torrent: SimpleNamespace) -> str | None: """Resolve the completed path for a torrent. Centralizes the logic shared by `get_status()` and `get_download_path()`: @@ -539,7 +567,6 @@ class QBittorrentClient(DownloadClient): - otherwise derive via properties+files - finally fall back to `save_path + name` """ - # Prefer content_path, but treat content_path == save_path as invalid. content_path = getattr(torrent, "content_path", "") save_path = getattr(torrent, "save_path", "") @@ -558,19 +585,20 @@ class QBittorrentClient(DownloadClient): getattr(torrent, "name", ""), ) - def _derive_download_path_from_files(self, download_id: str) -> Optional[str]: + def _derive_download_path_from_files(self, download_id: str) -> str | None: """Derive completed download path using `/torrents/properties` + `/torrents/files`. This mirrors how common automation apps derive the path when `content_path` isn't provided. """ import os + import requests def get_with_auth(url: str, params: dict[str, str]) -> requests.Response: self._client.auth_log_in() resp = self._client._session.get(url, params=params, timeout=10) - if resp.status_code == 403: + if resp.status_code == _HTTP_STATUS_FORBIDDEN: logger.debug("qBittorrent returned 403; re-authenticating and retrying") self._client.auth_log_in() resp = self._client._session.get(url, params=params, timeout=10) @@ -581,7 +609,7 @@ class QBittorrentClient(DownloadClient): files_url = f"{self._base_url}/api/v2/torrents/files" props_resp = get_with_auth(properties_url, {"hash": download_id}) - if props_resp.status_code == 404: + if props_resp.status_code == _HTTP_STATUS_NOT_FOUND: return None props_resp.raise_for_status() props = props_resp.json() if isinstance(props_resp.json(), dict) else {} @@ -591,7 +619,7 @@ class QBittorrentClient(DownloadClient): return None files_resp = get_with_auth(files_url, {"hash": download_id}) - if files_resp.status_code == 404: + if files_resp.status_code == _HTTP_STATUS_NOT_FOUND: return None files_resp.raise_for_status() files = files_resp.json() if isinstance(files_resp.json(), list) else [] @@ -608,14 +636,18 @@ class QBittorrentClient(DownloadClient): if not top_level: return None - return os.path.normpath(os.path.join(save_path, top_level)) + return os.path.normpath(str(Path(save_path) / top_level)) except Exception as e: - logger.debug(f"qBittorrent could not derive path from files: {type(e).__name__}: {e}") + logger.debug( + "qBittorrent could not derive path from files: %s: %s", + type(e).__name__, + e, + ) return None def find_existing( - self, url: str, category: Optional[str] = None - ) -> Optional[Tuple[str, DownloadStatus]]: + self, url: str, category: str | None = None + ) -> tuple[str, DownloadStatus] | None: """Check if a torrent for this URL already exists in qBittorrent.""" try: torrent_info = extract_torrent_info(url) @@ -624,7 +656,7 @@ class QBittorrentClient(DownloadClient): torrents, error = self._get_torrents_info(torrent_info.info_hash) if error: - logger.debug(f"qBittorrent find_existing: {error}") + logger.debug("qBittorrent find_existing: %s", error) return None torrent = next( @@ -632,15 +664,15 @@ class QBittorrentClient(DownloadClient): t for t in torrents if isinstance(getattr(t, "hash", None), str) - and _hashes_match(getattr(t, "hash"), torrent_info.info_hash) + and _hashes_match(t.hash, torrent_info.info_hash) ), None, ) if torrent and isinstance(getattr(torrent, "hash", None), str): - torrent_hash = getattr(torrent, "hash") + torrent_hash = torrent.hash return (torrent_hash.lower(), self.get_status(torrent_hash.lower())) - - return None except Exception as e: - logger.debug(f"Error checking for existing torrent: {e}") + logger.debug("Error checking for existing torrent: %s", e) + return None + else: return None diff --git a/shelfmark/download/clients/rtorrent.py b/shelfmark/download/clients/rtorrent.py index 670d6d1d..2124d1b0 100644 --- a/shelfmark/download/clients/rtorrent.py +++ b/shelfmark/download/clients/rtorrent.py @@ -1,17 +1,15 @@ -""" -rTorrent download client for Prowlarr integration. +"""rTorrent download client for Prowlarr integration. Uses xmlrpc to communicate with rTorrent's RPC interface. """ import ssl -from typing import Any, Optional, Tuple +from typing import NoReturn from urllib.parse import urlparse from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client -from shelfmark.download.network import get_ssl_verify +from shelfmark.core.utils import get_hardened_xmlrpc_client, normalize_http_url from shelfmark.download.clients import ( DownloadClient, DownloadStatus, @@ -20,11 +18,15 @@ from shelfmark.download.clients import ( from shelfmark.download.clients.torrent_utils import ( extract_torrent_info, ) +from shelfmark.download.network import get_ssl_verify logger = setup_logger(__name__) -def _create_rtorrent_server_proxy(url: str) -> Any: +_ETA_MAX_SECONDS = 604800 + + +def _create_rtorrent_server_proxy(url: str) -> object: """Create an XML-RPC ServerProxy honoring certificate validation mode.""" xmlrpc_client = get_hardened_xmlrpc_client() @@ -39,6 +41,10 @@ def _create_rtorrent_server_proxy(url: str) -> Any: return xmlrpc_client.ServerProxy(url) +def _raise_runtime_error(message: str) -> NoReturn: + raise RuntimeError(message) + + @register_client("torrent") class RTorrentClient(DownloadClient): """rTorrent download client using xmlrpc.""" @@ -46,24 +52,24 @@ class RTorrentClient(DownloadClient): protocol = "torrent" name = "rtorrent" - def __init__(self): + def __init__(self) -> None: """Initialize rTorrent client with settings from config.""" raw_url = config.get("RTORRENT_URL", "") if not raw_url: - raise ValueError("RTORRENT_URL is required") + msg = "RTORRENT_URL is required" + raise ValueError(msg) self._base_url = normalize_http_url(raw_url) if not self._base_url: - raise ValueError("RTORRENT_URL is invalid") + msg = "RTORRENT_URL is invalid" + raise ValueError(msg) username = config.get("RTORRENT_USERNAME", "") password = config.get("RTORRENT_PASSWORD", "") if username and password: parsed = urlparse(self._base_url) - self._base_url = ( - f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}" - ) + self._base_url = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}" self._rpc = _create_rtorrent_server_proxy(self._base_url) self._download_dir = config.get("RTORRENT_DOWNLOAD_DIR", "") @@ -76,24 +82,24 @@ class RTorrentClient(DownloadClient): url = normalize_http_url(config.get("RTORRENT_URL", "")) return client == "rtorrent" and bool(url) - def test_connection(self) -> Tuple[bool, str]: + def test_connection(self) -> tuple[bool, str]: """Test connection to rTorrent.""" try: version = self._rpc.system.client_version() - return True, f"Connected to rTorrent {version}" except Exception as e: - return False, f"Connection failed: {str(e)}" + return False, f"Connection failed: {e!s}" + else: + return True, f"Connected to rTorrent {version}" def add_download( self, url: str, name: str, - category: Optional[str] = None, - expected_hash: Optional[str] = None, + category: str | None = None, + expected_hash: str | None = None, **kwargs, ) -> str: - """ - Add torrent by URL (magnet or .torrent). + """Add torrent by URL (magnet or .torrent). Args: url: Magnet link or .torrent URL @@ -106,6 +112,7 @@ class RTorrentClient(DownloadClient): Raises: Exception: If adding fails. + """ try: torrent_info = extract_torrent_info(url, expected_hash=expected_hash) @@ -114,44 +121,53 @@ class RTorrentClient(DownloadClient): label = category or self._label if label: - logger.debug(f"Setting rTorrent label: {label}") + logger.debug("Setting rTorrent label: %s", label) commands.append(f"d.custom1.set={label}") download_dir = self._download_dir or self._get_download_dir() if download_dir: - logger.debug(f"Setting rTorrent download directory: {download_dir}") + logger.debug("Setting rTorrent download directory: %s", download_dir) commands.append(f"d.directory.set={download_dir}") if torrent_info.torrent_data: - logger.debug(f"Adding torrent data directly to rTorrent for: {name} with commands: {commands} with data size: {len(torrent_info.torrent_data)}") - self._rpc.load.raw_start( - "", torrent_info.torrent_data, ";".join(commands) + logger.debug( + "Adding torrent data directly to rTorrent for: %s with commands: %s with data size: %s", + name, + commands, + len(torrent_info.torrent_data), ) + self._rpc.load.raw_start("", torrent_info.torrent_data, ";".join(commands)) else: - logger.debug(f"Adding torrent URL to rTorrent for: {name} with commands: {commands} with URL: {url}") + logger.debug( + "Adding torrent URL to rTorrent for: %s with commands: %s with URL: %s", + name, + commands, + url, + ) add_url = torrent_info.magnet_url or url self._rpc.load.start("", add_url, ";".join(commands)) torrent_hash = torrent_info.info_hash or expected_hash if not torrent_hash: - raise Exception("Could not determine torrent hash from URL") + _raise_runtime_error("Could not determine torrent hash from URL") - logger.debug(f"Added torrent to rTorrent: {torrent_hash}") + logger.debug("Added torrent to rTorrent: %s", torrent_hash) + + except Exception: + logger.exception("rTorrent add failed") + raise + else: return torrent_hash - except Exception as e: - logger.error(f"rTorrent add failed: {e}") - raise - def get_status(self, download_id: str) -> DownloadStatus: - """ - Get torrent status by hash. + """Get torrent status by hash. Args: download_id: Torrent info_hash Returns: Current download status. + """ try: # rtorrent is somehow case sensitive and requires uppercase hashes for look @@ -169,25 +185,29 @@ class RTorrentClient(DownloadClient): "d.complete=", ) torrent_list = [t for t in all_torrents if t and t[0] == download_id] - logger.debug(f"Fetched torrent status from rTorrent for: {download_id} - {torrent_list}") + logger.debug( + "Fetched torrent status from rTorrent for: %s - %s", + download_id, + torrent_list, + ) if not torrent_list: - logger.warning(f"Torrent not found in rTorrent: {download_id}") + logger.warning("Torrent not found in rTorrent: %s", download_id) return DownloadStatus.error("Torrent not found") torrent = torrent_list[0] if not torrent: - logger.warning(f"Torrent data is empty for: {download_id}") + logger.warning("Torrent data is empty for: %s", download_id) return DownloadStatus.error("Torrent not found") - logger.debug(f"Torrent data for {download_id}: {torrent}") + logger.debug("Torrent data for %s: %s", download_id, torrent) ( - torrent_hash, + _torrent_hash, state, bytes_downloaded, bytes_total, down_rate, - up_rate, - custom_category, + _up_rate, + _custom_category, complete, ) = torrent @@ -198,10 +218,7 @@ class RTorrentClient(DownloadClient): complete = bool(complete) - if bytes_total > 0: - progress = (bytes_downloaded / bytes_total) * 100 - else: - progress = 0 + progress = (bytes_downloaded / bytes_total) * 100 if bytes_total > 0 else 0 bytes_left = max(0, bytes_total - bytes_downloaded) @@ -221,7 +238,7 @@ class RTorrentClient(DownloadClient): eta = None if down_rate > 0 and bytes_left > 0: eta_seconds = bytes_left / down_rate - if eta_seconds < 604800: + if eta_seconds < _ETA_MAX_SECONDS: eta = int(eta_seconds) file_path = None @@ -240,12 +257,11 @@ class RTorrentClient(DownloadClient): except Exception as e: error_type = type(e).__name__ - logger.error(f"rTorrent get_status failed ({error_type}): {e}") + logger.exception("rTorrent get_status failed (%s)", error_type) return DownloadStatus.error(f"{error_type}: {e}") - def remove(self, download_id: str, delete_files: bool = False) -> bool: - """ - Remove a torrent from rTorrent. + def remove(self, download_id: str, *, delete_files: bool = False) -> bool: + """Remove a torrent from rTorrent. Args: download_id: Torrent info_hash @@ -253,6 +269,7 @@ class RTorrentClient(DownloadClient): Returns: True if successful. + """ try: if delete_files: @@ -263,35 +280,37 @@ class RTorrentClient(DownloadClient): self._rpc.d.erase(download_id) logger.info( - f"Removed torrent from rTorrent: {download_id}" - + (" (with files)" if delete_files else "") + "Removed torrent from rTorrent: %s%s", + download_id, + " (with files)" if delete_files else "", ) - return True except Exception as e: error_type = type(e).__name__ - logger.error(f"rTorrent remove failed ({error_type}): {e}") + logger.exception("rTorrent remove failed (%s)", error_type) return False + else: + return True - def get_download_path(self, download_id: str) -> Optional[str]: - """ - Get the path where torrent files are located. + def get_download_path(self, download_id: str) -> str | None: + """Get the path where torrent files are located. Args: download_id: Torrent info_hash Returns: Content path (file or directory), or None. + """ try: return self._get_torrent_path(download_id) except Exception as e: error_type = type(e).__name__ - logger.debug(f"rTorrent get_download_path failed ({error_type}): {e}") + logger.debug("rTorrent get_download_path failed (%s): %s", error_type, e) return None def find_existing( - self, url: str, category: Optional[str] = None - ) -> Optional[Tuple[str, DownloadStatus]]: + self, url: str, category: str | None = None + ) -> tuple[str, DownloadStatus] | None: """Check if a torrent for this URL already exists in rTorrent.""" try: torrent_info = extract_torrent_info(url) @@ -304,21 +323,20 @@ class RTorrentClient(DownloadClient): return (torrent_info.info_hash, status) except Exception: pass - - return None except Exception as e: - logger.debug(f"Error checking for existing torrent: {e}") + logger.debug("Error checking for existing torrent: %s", e) + return None + else: return None def _get_download_dir(self) -> str: """Get the download directory from rTorrent config.""" try: - download_dir = self._rpc.directory.default() - return download_dir + return self._rpc.directory.default() except Exception: return "/downloads" - def _get_torrent_path(self, download_id: str) -> Optional[str]: + def _get_torrent_path(self, download_id: str) -> str | None: """Get the file path of a torrent by hash. Uses `d.base_path` for the item output path. In the xmlrpc interface @@ -337,6 +355,7 @@ class RTorrentClient(DownloadClient): if not details: return None path = details[0][0] - return path if path else None except Exception: return None + else: + return path or None diff --git a/shelfmark/download/clients/sabnzbd.py b/shelfmark/download/clients/sabnzbd.py index 3b6df5c8..42d1659b 100644 --- a/shelfmark/download/clients/sabnzbd.py +++ b/shelfmark/download/clients/sabnzbd.py @@ -1,10 +1,8 @@ -""" -SABnzbd download client for Prowlarr integration. +"""SABnzbd download client for Prowlarr integration. Uses SABnzbd's REST API directly via requests (no external dependency). """ -from typing import Any, Optional, Tuple from urllib.parse import urlparse import requests @@ -12,38 +10,41 @@ import requests from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url -from shelfmark.download.network import get_ssl_verify from shelfmark.download.clients import ( DownloadClient, DownloadStatus, register_client, with_retry, ) +from shelfmark.download.network import get_ssl_verify logger = setup_logger(__name__) +_ETA_PART_COUNT = 3 +_SPEED_PARTS_MIN = 2 -def _parse_eta(eta_str: str) -> Optional[int]: + +def _parse_eta(eta_str: str) -> int | None: """Parse SABnzbd ETA string (format: 'H:MM:SS') to seconds.""" if not eta_str or eta_str == "0:00:00": return None try: parts = eta_str.split(":") - if len(parts) == 3: + if len(parts) == _ETA_PART_COUNT: return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) - except (ValueError, IndexError): + except ValueError, IndexError: pass return None -def _parse_speed(slot: dict) -> Optional[int]: +def _parse_speed(slot: dict) -> int | None: """Parse download speed from SABnzbd slot data, returning bytes/sec.""" # Prefer kbpersec field (more reliable numeric value) kbpersec_str = slot.get("kbpersec", "") if kbpersec_str: try: return int(float(kbpersec_str) * 1024) - except (ValueError, TypeError): + except ValueError, TypeError: pass # Fall back to human-readable speed field @@ -53,7 +54,7 @@ def _parse_speed(slot: dict) -> Optional[int]: try: speed_parts = speed_str.split() - if len(speed_parts) < 2: + if len(speed_parts) < _SPEED_PARTS_MIN: return None speed_val = float(speed_parts[0]) unit = speed_parts[1].upper() @@ -62,7 +63,7 @@ def _parse_speed(slot: dict) -> Optional[int]: if prefix in unit: return int(speed_val * mult) return int(speed_val) - except (ValueError, IndexError): + except ValueError, IndexError: return None @@ -100,19 +101,22 @@ class SABnzbdClient(DownloadClient): protocol = "usenet" name = "sabnzbd" - def __init__(self): + def __init__(self) -> None: """Initialize SABnzbd client with settings from config.""" raw_url = config.get("SABNZBD_URL", "") if not raw_url: - raise ValueError("SABNZBD_URL is required") + msg = "SABNZBD_URL is required" + raise ValueError(msg) api_key = config.get("SABNZBD_API_KEY", "") if not api_key: - raise ValueError("SABNZBD_API_KEY is required") + msg = "SABNZBD_API_KEY is required" + raise ValueError(msg) self.url = normalize_http_url(raw_url) if not self.url: - raise ValueError("SABNZBD_URL is invalid") + msg = "SABNZBD_URL is invalid" + raise ValueError(msg) self.api_key = api_key self._category = config.get("SABNZBD_CATEGORY", "books") @@ -125,9 +129,8 @@ class SABnzbdClient(DownloadClient): return client == "sabnzbd" and bool(url) and bool(api_key) @with_retry() - def _api_call(self, mode: str, params: Optional[dict] = None) -> Any: - """ - Make an API call to SABnzbd. + def _api_call(self, mode: str, params: dict | None = None) -> object: + """Make an API call to SABnzbd. Args: mode: API mode (e.g., "version", "addurl", "queue", "history") @@ -138,6 +141,7 @@ class SABnzbdClient(DownloadClient): Raises: Exception: If API call fails after retries. + """ api_url = f"{self.url}/api" @@ -149,7 +153,9 @@ class SABnzbdClient(DownloadClient): if params: request_params.update(params) - response = requests.get(api_url, params=request_params, timeout=30, verify=get_ssl_verify(api_url)) + response = requests.get( + api_url, params=request_params, timeout=30, verify=get_ssl_verify(api_url) + ) response.raise_for_status() result = response.json() @@ -157,16 +163,19 @@ class SABnzbdClient(DownloadClient): # Check for error in response if isinstance(result, dict) and result.get("status") is False: error = result.get("error", "Unknown error") - raise Exception(f"SABnzbd error: {error}") + msg = f"SABnzbd error: {error}" + raise RuntimeError(msg) return result - def _api_post_file(self, nzb_content: bytes, filename: str, nzb_name: str, category: str) -> Any: - """ - Upload an NZB file to SABnzbd using addfile. + def _api_post_file( + self, nzb_content: bytes, filename: str, nzb_name: str, category: str + ) -> object: + """Upload an NZB file to SABnzbd using addfile. Returns: JSON response from SABnzbd. + """ api_url = f"{self.url}/api" request_params = { @@ -178,13 +187,20 @@ class SABnzbdClient(DownloadClient): } files = {"name": (filename, nzb_content, "application/x-nzb")} - response = requests.post(api_url, params=request_params, files=files, timeout=30, verify=get_ssl_verify(api_url)) + response = requests.post( + api_url, + params=request_params, + files=files, + timeout=30, + verify=get_ssl_verify(api_url), + ) response.raise_for_status() result = response.json() if isinstance(result, dict) and result.get("status") is False: error = result.get("error", "Unknown error") - raise Exception(f"SABnzbd error: {error}") + msg = f"SABnzbd error: {error}" + raise RuntimeError(msg) return result @@ -242,9 +258,10 @@ class SABnzbdClient(DownloadClient): return f"{base_name}.nzb" @staticmethod - def _extract_nzo_id(result: Any) -> str: + def _extract_nzo_id(result: object) -> str: if not isinstance(result, dict): - raise Exception("SABnzbd returned invalid response") + msg = "SABnzbd returned invalid response" + raise TypeError(msg) nzo_ids = result.get("nzo_ids") or result.get("nzo_id") if isinstance(nzo_ids, list) and nzo_ids: @@ -254,31 +271,32 @@ class SABnzbdClient(DownloadClient): if isinstance(nzo_ids, int): return str(nzo_ids) - raise Exception("SABnzbd returned no nzo_id") + msg = "SABnzbd returned no nzo_id" + raise RuntimeError(msg) - def test_connection(self) -> Tuple[bool, str]: + def test_connection(self) -> tuple[bool, str]: """Test connection to SABnzbd.""" try: result = self._api_call("version") version = result.get("version", "unknown") - return True, f"Connected to SABnzbd {version}" except requests.exceptions.ConnectionError: return False, "Could not connect to SABnzbd" except requests.exceptions.Timeout: return False, "Connection timed out" except Exception as e: - return False, f"Connection failed: {str(e)}" + return False, f"Connection failed: {e!s}" + else: + return True, f"Connected to SABnzbd {version}" def add_download( self, url: str, name: str, - category: Optional[str] = None, - expected_hash: Optional[str] = None, + category: str | None = None, + expected_hash: str | None = None, **kwargs, ) -> str: - """ - Add NZB by URL. + """Add NZB by URL. Args: url: NZB URL (can be Prowlarr proxy URL) @@ -291,20 +309,22 @@ class SABnzbdClient(DownloadClient): Raises: Exception: If adding fails. + """ # Use configured category if not explicitly provided category = category or self._category try: - logger.debug(f"Adding NZB to SABnzbd: {name}") + logger.debug("Adding NZB to SABnzbd: %s", name) nzb_filename = self._build_nzb_filename(name, url) nzb_content = self._fetch_nzb_content(url) result = self._api_post_file(nzb_content, nzb_filename, name, category) nzo_id = self._extract_nzo_id(result) - logger.info(f"Added NZB to SABnzbd: {nzo_id}") - return nzo_id + logger.info("Added NZB to SABnzbd: %s", nzo_id) except Exception as e: - logger.warning(f"SABnzbd addfile failed, falling back to addurl: {e}") + logger.warning("SABnzbd addfile failed, falling back to addurl: %s", e) + else: + return nzo_id try: result = self._api_call( @@ -316,21 +336,22 @@ class SABnzbdClient(DownloadClient): }, ) nzo_id = self._extract_nzo_id(result) - logger.info(f"Added NZB to SABnzbd via addurl: {nzo_id}") - return nzo_id - except Exception as e: - logger.error(f"SABnzbd add failed: {e}") + logger.info("Added NZB to SABnzbd via addurl: %s", nzo_id) + except Exception: + logger.exception("SABnzbd add failed") raise + else: + return nzo_id def get_status(self, download_id: str) -> DownloadStatus: - """ - Get NZB status by nzo_id. + """Get NZB status by nzo_id. Args: download_id: SABnzbd nzo_id Returns: Current download status. + """ try: # Check active queue first @@ -383,7 +404,12 @@ class SABnzbdClient(DownloadClient): storage = slot.get("storage", "") if storage is None: storage = "" - logger.debug(f"SABnzbd history: {download_id} status={status_text} storage='{storage}'") + logger.debug( + "SABnzbd history: %s status=%s storage='%s'", + download_id, + status_text, + storage, + ) if status_text == "COMPLETED": title = slot.get("name") or slot.get("nzb_name") or "" @@ -396,7 +422,7 @@ class SABnzbdClient(DownloadClient): complete=True, file_path=resolved_storage, ) - elif status_text == "FAILED": + if status_text == "FAILED": fail_message = slot.get("fail_message", "Download failed") title = slot.get("name") or slot.get("nzb_name") or "" resolved_storage = self._resolve_completed_storage_path(storage, title) @@ -407,27 +433,25 @@ class SABnzbdClient(DownloadClient): complete=True, file_path=resolved_storage, ) - else: - # Post-processing states: Queued, QuickCheck, Verifying, - # Repairing, Fetching, Extracting, Moving, Running - # Keep polling - not yet complete - return DownloadStatus( - progress=100, - state="processing", - message=status_text.title(), - complete=False, - file_path=None, - ) + # Post-processing states: Queued, QuickCheck, Verifying, + # Repairing, Fetching, Extracting, Moving, Running + # Keep polling - not yet complete + return DownloadStatus( + progress=100, + state="processing", + message=status_text.title(), + complete=False, + file_path=None, + ) # Not found - logger.warning(f"SABnzbd: download {download_id} not found in queue or history") + logger.warning("SABnzbd: download %s not found in queue or history", download_id) return DownloadStatus.error("Download not found") except Exception as e: return DownloadStatus.error(self._log_error("get_status", e)) - def remove(self, download_id: str, delete_files: bool = False, archive: bool = True) -> bool: - """ - Remove a download from SABnzbd. + def remove(self, download_id: str, *, delete_files: bool = False, archive: bool = True) -> bool: + """Remove a download from SABnzbd. Args: download_id: SABnzbd nzo_id @@ -436,6 +460,7 @@ class SABnzbdClient(DownloadClient): Returns: True if successful. + """ # First try to remove from queue. If it isn't there (common for completed jobs), # fall back to history removal instead of failing fast on a SABnzbd error response. @@ -450,10 +475,10 @@ class SABnzbdClient(DownloadClient): ) if result.get("status"): - logger.info(f"Removed NZB from SABnzbd queue: {download_id}") + logger.info("Removed NZB from SABnzbd queue: %s", download_id) return True except Exception as e: - logger.debug(f"SABnzbd queue delete skipped for {download_id}: {e}") + logger.debug("SABnzbd queue delete skipped for %s: %s", download_id, e) # If not in queue (or queue delete failed), try to remove from history. try: @@ -469,7 +494,7 @@ class SABnzbdClient(DownloadClient): if result.get("status"): action = "archived" if archive else "removed" - logger.info(f"NZB {action} from SABnzbd history: {download_id}") + logger.info("NZB %s from SABnzbd history: %s", action, download_id) return True except Exception as e: self._log_error("remove", e) @@ -477,24 +502,23 @@ class SABnzbdClient(DownloadClient): return False - def get_download_path(self, download_id: str) -> Optional[str]: - """ - Get the path where NZB files are located. + def get_download_path(self, download_id: str) -> str | None: + """Get the path where NZB files are located. Args: download_id: SABnzbd nzo_id Returns: Storage directory, or None. + """ status = self.get_status(download_id) return status.file_path def find_existing( - self, url: str, category: Optional[str] = None - ) -> Optional[Tuple[str, DownloadStatus]]: - """ - Check if an NZB for this URL already exists in SABnzbd. + self, url: str, category: str | None = None + ) -> tuple[str, DownloadStatus] | None: + """Check if an NZB for this URL already exists in SABnzbd. Note: Unlike torrents which have a unique info_hash, usenet NZBs don't have a universal unique identifier. SABnzbd generates an nzo_id when adding, @@ -507,23 +531,22 @@ class SABnzbdClient(DownloadClient): Returns: Tuple of (nzo_id, status) if found, None if not found. + """ try: # Extract NZB name from URL (last path component without extension) from urllib.parse import unquote, urlparse + parsed = urlparse(url) path = unquote(parsed.path) # Get filename from path - if "/" in path: - filename = path.rsplit("/", 1)[-1] - else: - filename = path + filename = path.rsplit("/", 1)[-1] if "/" in path else path # Remove common NZB extensions for ext in [".nzb", ".nzb.gz"]: if filename.lower().endswith(ext): - filename = filename[:-len(ext)] + filename = filename[: -len(ext)] break if not filename: @@ -543,7 +566,7 @@ class SABnzbdClient(DownloadClient): nzo_id = slot.get("nzo_id") if nzo_id: status = self.get_status(nzo_id) - logger.debug(f"Found existing NZB in SABnzbd queue: {nzo_id}") + logger.debug("Found existing NZB in SABnzbd queue: %s", nzo_id) return (nzo_id, status) # Search history (SABnzbd uses "category" field in history) @@ -557,11 +580,11 @@ class SABnzbdClient(DownloadClient): nzo_id = slot.get("nzo_id") if nzo_id: status = self.get_status(nzo_id) - logger.debug(f"Found existing NZB in SABnzbd history: {nzo_id}") + logger.debug("Found existing NZB in SABnzbd history: %s", nzo_id) return (nzo_id, status) - return None - except Exception as e: - logger.debug(f"Error checking for existing NZB: {e}") + logger.debug("Error checking for existing NZB: %s", e) + return None + else: return None diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index c07cd1c6..e51c83fb 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -1,25 +1,33 @@ """Shared download client settings registration.""" -from contextlib import contextmanager -from typing import Any, Dict, Optional +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING, Any, NoReturn from shelfmark.core.settings_registry import ( - register_settings, - HeadingField, - TextField, - PasswordField, ActionButton, + HeadingField, + PasswordField, SelectField, + SettingsField, TagListField, + TextField, + register_settings, ) -from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client +from shelfmark.core.utils import get_hardened_xmlrpc_client, normalize_http_url from shelfmark.download.network import get_ssl_verify +if TYPE_CHECKING: + from collections.abc import Iterator # ==================== Test Connection Callbacks ==================== + +def _raise_runtime_error(message: str) -> NoReturn: + raise RuntimeError(message) + + @contextmanager -def _transmission_session_verify_override(url: str): +def _transmission_session_verify_override(url: str) -> Iterator[None]: """Ensure transmission-rpc constructor uses the configured TLS verify mode.""" verify = get_ssl_verify(url) if verify: @@ -28,7 +36,7 @@ def _transmission_session_verify_override(url: str): try: import transmission_rpc.client as transmission_rpc_client - except Exception: + except ImportError: yield return @@ -46,7 +54,7 @@ def _transmission_session_verify_override(url: str): transmission_rpc_client.requests.Session = original_session_factory -def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: """Test the qBittorrent connection using current form values.""" from shelfmark.core.config import config @@ -66,17 +74,23 @@ def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None if not url: return {"success": False, "message": "qBittorrent URL is invalid"} - client = Client(host=url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url)) + client = Client( + host=url, + username=username, + password=password, + VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url), + ) client.auth_log_in() api_version = client.app.web_api_version - return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"} except ImportError: return {"success": False, "message": "qbittorrent-api package not installed"} except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} + return {"success": False, "message": f"Connection failed: {e!s}"} + else: + return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"} -def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def _test_transmission_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: """Test the Transmission connection using current form values.""" from shelfmark.core.config import config from shelfmark.download.clients.torrent_utils import ( @@ -86,8 +100,12 @@ def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = Non current_values = current_values or {} raw_url = current_values.get("TRANSMISSION_URL") or config.get("TRANSMISSION_URL", "") - username = current_values.get("TRANSMISSION_USERNAME") or config.get("TRANSMISSION_USERNAME", "") - password = current_values.get("TRANSMISSION_PASSWORD") or config.get("TRANSMISSION_PASSWORD", "") + username = current_values.get("TRANSMISSION_USERNAME") or config.get( + "TRANSMISSION_USERNAME", "" + ) + password = current_values.get("TRANSMISSION_PASSWORD") or config.get( + "TRANSMISSION_PASSWORD", "" + ) if not raw_url: return {"success": False, "message": "Transmission URL is required"} @@ -106,8 +124,8 @@ def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = Non "host": host, "port": port, "path": path, - "username": username if username else None, - "password": password if password else None, + "username": username or None, + "password": password or None, "protocol": protocol, } try: @@ -119,11 +137,9 @@ def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = Non client_kwargs.pop("protocol", None) with _transmission_session_verify_override(url): client = Client(**client_kwargs) - if protocol == "https" and hasattr(client, "protocol"): - try: - setattr(client, "protocol", protocol) - except Exception: - pass + if protocol == "https" and hasattr(client, "protocol"): + with suppress(Exception): + client.protocol = protocol # Keep session verify aligned for subsequent calls beyond constructor bootstrap. http_session = getattr(client, "_http_session", None) @@ -132,18 +148,20 @@ def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = Non session = client.get_session() version = session.version - return {"success": True, "message": f"Connected to Transmission {version}"} except ImportError: return {"success": False, "message": "transmission-rpc package not installed"} except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} + return {"success": False, "message": f"Connection failed: {e!s}"} + else: + return {"success": True, "message": f"Connected to Transmission {version}"} -def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def _test_deluge_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: """Test Deluge Web UI JSON-RPC connection using current form values.""" from urllib.parse import urlparse import requests + from shelfmark.core.config import config current_values = current_values or {} @@ -177,13 +195,12 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> if parsed.port is not None: port = parsed.port base_path = (parsed.path or "").rstrip("/") - else: - # Allow "host:port" in DELUGE_HOST for convenience. - if ":" in raw_host and raw_host.count(":") == 1: - host_part, port_part = raw_host.split(":", 1) - if host_part and port_part.isdigit(): - host = host_part - port = int(port_part) + # Allow "host:port" in DELUGE_HOST for convenience. + elif ":" in raw_host and raw_host.count(":") == 1: + host_part, port_part = raw_host.split(":", 1) + if host_part and port_part.isdigit(): + host = host_part + port = int(port_part) rpc_url = f"{scheme}://{host}:{port}{base_path}/json" @@ -195,8 +212,8 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> if data.get("error"): error = data["error"] if isinstance(error, dict): - raise Exception(error.get("message") or str(error)) - raise Exception(str(error)) + raise RuntimeError(error.get("message") or str(error)) + raise RuntimeError(str(error)) return data.get("result") def get_daemon_version(session: requests.Session, rpc_id: int) -> Any: @@ -204,7 +221,7 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> methods = rpc_call(session, rpc_id, "system.listMethods") if isinstance(methods, list) and "daemon.get_version" in methods: return rpc_call(session, rpc_id + 1, "daemon.get_version") - except Exception: + except requests.exceptions.RequestException, RuntimeError, ValueError, TypeError: # Fall back to daemon.info to preserve existing behavior. pass @@ -226,7 +243,11 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> host_id = hosts[0][0] for entry in hosts: - if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}: + if ( + isinstance(entry, list) + and len(entry) >= 2 + and entry[1] in {"127.0.0.1", "localhost"} + ): host_id = entry[0] break @@ -239,22 +260,31 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> } version = get_daemon_version(session, 6) - return {"success": True, "message": f"Connected to Deluge {version}"} - except requests.exceptions.ConnectionError: return {"success": False, "message": "Could not connect to Deluge Web UI"} except requests.exceptions.Timeout: return {"success": False, "message": "Connection timed out"} - except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} + except ( + requests.exceptions.RequestException, + RuntimeError, + ValueError, + TypeError, + KeyError, + IndexError, + AttributeError, + ) as e: + return {"success": False, "message": f"Connection failed: {e!s}"} + else: + return {"success": True, "message": f"Connected to Deluge {version}"} -def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def _test_rtorrent_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: """Test the rTorrent connection using current form values.""" - from shelfmark.core.config import config import ssl from urllib.parse import urlparse + from shelfmark.core.config import config + current_values = current_values or {} raw_url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "") @@ -290,14 +320,17 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) - rpc = xmlrpc_client.ServerProxy(rpc_url) version = rpc.system.client_version() + except (xmlrpc_client.Error, RuntimeError, OSError, ValueError, TypeError) as e: + return {"success": False, "message": f"Connection failed: {e!s}"} + + else: return {"success": True, "message": f"Connected to rTorrent {version}"} - except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} -def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def _test_nzbget_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: """Test the NZBGet connection using current form values.""" import requests + from shelfmark.core.config import config current_values = current_values or {} @@ -316,24 +349,38 @@ def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) -> try: rpc_url = f"{url.rstrip('/')}/jsonrpc" payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1} - response = requests.post(rpc_url, json=payload, auth=(username, password), timeout=30, verify=get_ssl_verify(rpc_url)) + response = requests.post( + rpc_url, + json=payload, + auth=(username, password), + timeout=30, + verify=get_ssl_verify(rpc_url), + ) response.raise_for_status() result = response.json() - if "error" in result and result["error"]: - raise Exception(result["error"].get("message", "RPC error")) + if result.get("error"): + _raise_runtime_error(result["error"].get("message", "RPC error")) version = result.get("result", {}).get("Version", "unknown") - return {"success": True, "message": f"Connected to NZBGet {version}"} except requests.exceptions.ConnectionError: return {"success": False, "message": "Could not connect to NZBGet"} except requests.exceptions.Timeout: return {"success": False, "message": "Connection timed out"} - except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} + except ( + requests.exceptions.RequestException, + RuntimeError, + ValueError, + AttributeError, + TypeError, + ) as e: + return {"success": False, "message": f"Connection failed: {e!s}"} + else: + return {"success": True, "message": f"Connected to NZBGet {version}"} -def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def _test_sabnzbd_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: """Test the SABnzbd connection using current form values.""" import requests + from shelfmark.core.config import config current_values = current_values or {} @@ -357,24 +404,32 @@ def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) -> response.raise_for_status() result = response.json() version = result.get("version", "unknown") - return {"success": True, "message": f"Connected to SABnzbd {version}"} except requests.exceptions.ConnectionError: return {"success": False, "message": "Could not connect to SABnzbd"} except requests.exceptions.Timeout: return {"success": False, "message": "Connection timed out"} - except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} + except ( + requests.exceptions.RequestException, + RuntimeError, + ValueError, + AttributeError, + TypeError, + ) as e: + return {"success": False, "message": f"Connection failed: {e!s}"} + else: + return {"success": True, "message": f"Connected to SABnzbd {version}"} # ==================== Download Clients Tab ==================== + @register_settings( name="prowlarr_clients", display_name="Download Clients", icon="cog", order=110, ) -def prowlarr_clients_settings(): +def prowlarr_clients_settings() -> list[SettingsField]: """Download client settings shared by external release sources.""" return [ # --- Torrent Client Selection --- @@ -396,7 +451,6 @@ def prowlarr_clients_settings(): ], default="", ), - # --- qBittorrent Settings --- TextField( key="QBITTORRENT_URL", @@ -458,7 +512,6 @@ def prowlarr_clients_settings(): normalize_urls=False, show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, ), - # --- Transmission Settings --- TextField( key="TRANSMISSION_URL", @@ -510,7 +563,6 @@ def prowlarr_clients_settings(): placeholder="/downloads", show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, ), - # --- Deluge Settings --- TextField( key="DELUGE_HOST", @@ -565,7 +617,6 @@ def prowlarr_clients_settings(): placeholder="/downloads", show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, ), - # --- rTorrent Settings --- TextField( key="RTORRENT_URL", @@ -621,7 +672,6 @@ def prowlarr_clients_settings(): default="keep", show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True}, ), - # --- Usenet Client Selection --- HeadingField( key="usenet_heading", @@ -639,7 +689,6 @@ def prowlarr_clients_settings(): ], default="", ), - # --- NZBGet Settings --- TextField( key="NZBGET_URL", @@ -686,7 +735,6 @@ def prowlarr_clients_settings(): default="", show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, ), - # --- SABnzbd Settings --- TextField( key="SABNZBD_URL", diff --git a/shelfmark/download/clients/torrent_utils.py b/shelfmark/download/clients/torrent_utils.py index eff45698..068f03ae 100644 --- a/shelfmark/download/clients/torrent_utils.py +++ b/shelfmark/download/clients/torrent_utils.py @@ -4,7 +4,6 @@ import base64 import hashlib import re from dataclasses import dataclass -from typing import Optional, Tuple from urllib.parse import parse_qs, urljoin, urlparse import requests @@ -15,24 +14,32 @@ from shelfmark.download.network import get_ssl_verify logger = setup_logger(__name__) +_MAGNET_RESPONSE_MAX_BYTES = 2000 +_BASE32_BTMH_TAG_BYTES = 34 +_BTIH_INFO_BYTE_HEX = 0x20 +_BTIH_PREFIX_BYTE = 0x12 +_BTIH_DIGEST_LENGTH = 32 +_BTIH_HASH_LENGTH_40 = 40 +_BTIH_HASH_LENGTH_32 = 32 + @dataclass class TorrentInfo: """Parsed information from a torrent URL.""" - info_hash: Optional[str] + info_hash: str | None """Lowercase hex info_hash (32 or 40 chars), or None if extraction failed.""" - torrent_data: Optional[bytes] + torrent_data: bytes | None """Raw .torrent file content, only populated for .torrent URLs.""" is_magnet: bool """True if the URL was a magnet link.""" - magnet_url: Optional[str] = None + magnet_url: str | None = None """The actual magnet URL, if available.""" - def with_info_hash(self, info_hash: Optional[str]) -> "TorrentInfo": + def with_info_hash(self, info_hash: str | None) -> TorrentInfo: """Return a copy with the info_hash replaced when provided.""" if info_hash: return TorrentInfo( @@ -46,8 +53,9 @@ class TorrentInfo: def extract_torrent_info( url: str, + *, fetch_torrent: bool = True, - expected_hash: Optional[str] = None, + expected_hash: str | None = None, ) -> TorrentInfo: """Extract info_hash from magnet link or .torrent URL. @@ -58,6 +66,7 @@ def extract_torrent_info( Redirects to magnet links are handled explicitly so we can extract a hash from the magnet when available. + """ is_magnet = url.startswith("magnet:") @@ -85,11 +94,17 @@ def extract_torrent_info( return urljoin(current, location) try: - logger.debug(f"Fetching torrent file from: {url[:80]}...") + logger.debug("Fetching torrent file from: %s...", url[:80]) # Use allow_redirects=False to handle magnet link redirects manually # Some indexers redirect download URLs to magnet links - resp = requests.get(url, timeout=30, allow_redirects=False, headers=headers, verify=get_ssl_verify(url)) + resp = requests.get( + url, + timeout=30, + allow_redirects=False, + headers=headers, + verify=get_ssl_verify(url), + ) # Check if this is a redirect to a magnet link if resp.status_code in (301, 302, 303, 307, 308): @@ -100,18 +115,26 @@ def extract_torrent_info( if not info_hash and expected_hash: info_hash = expected_hash return TorrentInfo( - info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=redirect_url + info_hash=info_hash, + torrent_data=None, + is_magnet=True, + magnet_url=redirect_url, ) # Not a magnet redirect, follow it manually - logger.debug(f"Following redirect to: {redirect_url[:80]}...") - resp = requests.get(redirect_url, timeout=30, headers=headers, verify=get_ssl_verify(redirect_url)) + logger.debug("Following redirect to: %s...", redirect_url[:80]) + resp = requests.get( + redirect_url, + timeout=30, + headers=headers, + verify=get_ssl_verify(redirect_url), + ) resp.raise_for_status() torrent_data = resp.content # Check if response is actually a magnet link (text response) # Some indexers return magnet links as plain text instead of redirecting - if len(torrent_data) < 2000: # Magnet links are typically short + if len(torrent_data) < _MAGNET_RESPONSE_MAX_BYTES: # Magnet links are typically short try: text_content = torrent_data.decode("utf-8", errors="ignore").strip() if text_content.startswith("magnet:"): @@ -120,23 +143,26 @@ def extract_torrent_info( if not info_hash and expected_hash: info_hash = expected_hash return TorrentInfo( - info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=text_content + info_hash=info_hash, + torrent_data=None, + is_magnet=True, + magnet_url=text_content, ) except Exception: pass # Not text, continue with torrent parsing info_hash = extract_info_hash_from_torrent(torrent_data) or expected_hash if info_hash: - logger.debug(f"Extracted hash from torrent file: {info_hash}") + logger.debug("Extracted hash from torrent file: %s", info_hash) else: logger.warning("Could not extract hash from torrent file") return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False) except Exception as e: - logger.debug(f"Could not fetch torrent file: {e}") + logger.debug("Could not fetch torrent file: %s", e) return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False) -def parse_transmission_url(url: str) -> Tuple[str, str, int, str]: +def parse_transmission_url(url: str) -> tuple[str, str, int, str]: """Parse Transmission URL into (protocol, host, port, path).""" parsed = urlparse(url) protocol = (parsed.scheme or "http").lower() @@ -155,89 +181,89 @@ def parse_transmission_url(url: str) -> Tuple[str, str, int, str]: def bencode_decode(data: bytes) -> tuple: """Decode bencoded data. Returns (value, remaining_bytes).""" - if data[0:1] == b'd': + if data[0:1] == b"d": # Dictionary result = {} data = data[1:] - while data[0:1] != b'e': + while data[0:1] != b"e": key, data = bencode_decode(data) value, data = bencode_decode(data) result[key] = value return result, data[1:] - elif data[0:1] == b'l': + if data[0:1] == b"l": # List result = [] data = data[1:] - while data[0:1] != b'e': + while data[0:1] != b"e": value, data = bencode_decode(data) result.append(value) return result, data[1:] - elif data[0:1] == b'i': + if data[0:1] == b"i": # Integer - end = data.index(b'e') - return int(data[1:end]), data[end + 1:] - elif data[0:1].isdigit(): + end = data.index(b"e") + return int(data[1:end]), data[end + 1 :] + if data[0:1].isdigit(): # Byte string - colon = data.index(b':') + colon = data.index(b":") length = int(data[:colon]) start = colon + 1 - return data[start:start + length], data[start + length:] - else: - first_byte = data[0:1] - raise ValueError( - f"Invalid bencode data: expected 'd', 'l', 'i', or digit, " - f"got {first_byte!r}. First 20 bytes: {data[:20]!r}" - ) + return data[start : start + length], data[start + length :] + first_byte = data[0:1] + msg = ( + f"Invalid bencode data: expected 'd', 'l', 'i', or digit, " + f"got {first_byte!r}. First 20 bytes: {data[:20]!r}" + ) + raise ValueError(msg) -def bencode_encode(data) -> bytes: +def bencode_encode(data: dict[str | bytes, object] | list[object] | int | bytes | str) -> bytes: """Encode data to bencode format.""" if isinstance(data, dict): # Keys must be sorted (bencode spec requirement) - result = b'd' + result = b"d" for key in sorted(data.keys()): result += bencode_encode(key) result += bencode_encode(data[key]) - result += b'e' + result += b"e" return result - elif isinstance(data, list): - result = b'l' + if isinstance(data, list): + result = b"l" for item in data: result += bencode_encode(item) - result += b'e' + result += b"e" return result - elif isinstance(data, int): - return f'i{data}e'.encode() - elif isinstance(data, bytes): - return f'{len(data)}:'.encode() + data - elif isinstance(data, str): - encoded = data.encode('utf-8') - return f'{len(encoded)}:'.encode() + encoded - else: - raise ValueError( - f"Cannot bencode type {type(data).__name__}: " - f"expected dict, list, int, bytes, or str. Value: {data!r}" - ) + if isinstance(data, int): + return f"i{data}e".encode() + if isinstance(data, bytes): + return f"{len(data)}:".encode() + data + if isinstance(data, str): + encoded = data.encode("utf-8") + return f"{len(encoded)}:".encode() + encoded + msg = ( + f"Cannot bencode type {type(data).__name__}: " + f"expected dict, list, int, bytes, or str. Value: {data!r}" + ) + raise ValueError(msg) -def extract_info_hash_from_torrent(torrent_data: bytes) -> Optional[str]: +def extract_info_hash_from_torrent(torrent_data: bytes) -> str | None: """Extract info_hash from .torrent file data.""" try: decoded, _ = bencode_decode(torrent_data) - if b'info' not in decoded: + if b"info" not in decoded: return None - info_bencoded = bencode_encode(decoded[b'info']) - info_dict = decoded[b'info'] - if isinstance(info_dict, dict) and b'pieces' in info_dict: + info_bencoded = bencode_encode(decoded[b"info"]) + info_dict = decoded[b"info"] + if isinstance(info_dict, dict) and b"pieces" in info_dict: return hashlib.sha1(info_bencoded).hexdigest().lower() return hashlib.sha256(info_bencoded).hexdigest().lower() except Exception as e: - logger.debug(f"Failed to parse torrent file: {e}") + logger.debug("Failed to parse torrent file: %s", e) return None -def extract_hash_from_magnet(magnet_url: str) -> Optional[str]: +def extract_hash_from_magnet(magnet_url: str) -> str | None: """Extract info_hash from a magnet URL.""" if not magnet_url.startswith("magnet:"): return None @@ -245,12 +271,12 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]: parsed = urlparse(magnet_url) params = parse_qs(parsed.query) - def extract_btmh(value: str) -> Optional[str]: + def extract_btmh(value: str) -> str | None: raw_value = value.strip() if not raw_value: return None - data: Optional[bytes] = None + data: bytes | None = None if re.fullmatch(r"[a-fA-F0-9]+", raw_value): if len(raw_value) % 2 != 0: return None @@ -268,12 +294,16 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]: if not data: return None - if len(data) >= 34 and data[0] == 0x12 and data[1] == 0x20: - digest = data[2:34] - if len(digest) == 32: + if ( + len(data) >= _BASE32_BTMH_TAG_BYTES + and data[0] == _BTIH_PREFIX_BYTE + and data[1] == _BTIH_INFO_BYTE_HEX + ): + digest = data[2:_BASE32_BTMH_TAG_BYTES] + if len(digest) == _BTIH_DIGEST_LENGTH: return digest.hex().lower() - if len(data) == 32: + if len(data) == _BTIH_HASH_LENGTH_32: return data.hex().lower() return None @@ -287,11 +317,13 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]: hash_value = match.group(1) # 40-char hex or 32-char hex (ED2K) - return as-is - if len(hash_value) == 40 or re.match(r'^[a-fA-F0-9]{32}$', hash_value): + if len(hash_value) == _BTIH_HASH_LENGTH_40 or re.match( + r"^[a-fA-F0-9]{32}$", hash_value + ): return hash_value.lower() # 32-char base32 - decode to hex - if re.match(r'^[A-Z2-7]{32}$', hash_value.upper()): + if re.match(r"^[A-Z2-7]{32}$", hash_value.upper()): try: return base64.b32decode(hash_value.upper()).hex().lower() except Exception: @@ -302,7 +334,7 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]: for xt in xt_values: if xt.startswith("urn:btmh:"): - btmh_value = xt[len("urn:btmh:"):] + btmh_value = xt[len("urn:btmh:") :] btmh_hash = extract_btmh(btmh_value) if btmh_hash: return btmh_hash diff --git a/shelfmark/download/clients/transmission.py b/shelfmark/download/clients/transmission.py index 6d8d9a9d..8676fb47 100644 --- a/shelfmark/download/clients/transmission.py +++ b/shelfmark/download/clients/transmission.py @@ -1,17 +1,14 @@ -""" -Transmission download client for Prowlarr integration. +"""Transmission download client for Prowlarr integration. Uses the transmission-rpc library to communicate with Transmission's RPC API. """ -from contextlib import contextmanager -from typing import Any, Iterator, Optional, Tuple - +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url -from shelfmark.download.network import get_ssl_verify from shelfmark.download.clients import ( DownloadClient, DownloadStatus, @@ -21,9 +18,16 @@ from shelfmark.download.clients.torrent_utils import ( extract_torrent_info, parse_transmission_url, ) +from shelfmark.download.network import get_ssl_verify + +if TYPE_CHECKING: + from collections.abc import Iterator logger = setup_logger(__name__) +_SEEDING_PROGRESS_PERCENT = 100 +_ETA_MAX_SECONDS = 604800 + @contextmanager def _transmission_session_verify_override(url: str) -> Iterator[None]: @@ -46,7 +50,7 @@ def _transmission_session_verify_override(url: str) -> Iterator[None]: original_session_factory = transmission_rpc_client.requests.Session - def _session_factory(*args: Any, **kwargs: Any) -> Any: + def _session_factory(*args: object, **kwargs: object) -> object: session = original_session_factory(*args, **kwargs) session.verify = False return session @@ -58,7 +62,7 @@ def _transmission_session_verify_override(url: str) -> Iterator[None]: transmission_rpc_client.requests.Session = original_session_factory -def _apply_transmission_ssl_verify(client: Any, url: str) -> None: +def _apply_transmission_ssl_verify(client: object, url: str) -> None: """Apply global certificate validation policy to transmission-rpc client.""" session = getattr(client, "_http_session", None) if session is None: @@ -76,17 +80,19 @@ class TransmissionClient(DownloadClient): protocol = "torrent" name = "transmission" - def __init__(self): + def __init__(self) -> None: """Initialize Transmission client with settings from config.""" from transmission_rpc import Client raw_url = config.get("TRANSMISSION_URL", "") if not raw_url: - raise ValueError("TRANSMISSION_URL is required") + msg = "TRANSMISSION_URL is required" + raise ValueError(msg) url = normalize_http_url(raw_url) if not url: - raise ValueError("TRANSMISSION_URL is invalid") + msg = "TRANSMISSION_URL is invalid" + raise ValueError(msg) username = config.get("TRANSMISSION_USERNAME", "") password = config.get("TRANSMISSION_PASSWORD", "") @@ -98,8 +104,8 @@ class TransmissionClient(DownloadClient): "host": host, "port": port, "path": path, - "username": username if username else None, - "password": password if password else None, + "username": username or None, + "password": password or None, "protocol": protocol, } try: @@ -114,10 +120,8 @@ class TransmissionClient(DownloadClient): self._client = Client(**client_kwargs) # Some versions expose protocol as an attribute rather than kwarg. if protocol == "https" and hasattr(self._client, "protocol"): - try: - setattr(self._client, "protocol", protocol) - except Exception: - pass + with suppress(Exception): + self._client.protocol = protocol _apply_transmission_ssl_verify(self._client, url) self._category = config.get("TRANSMISSION_CATEGORY", "books") self._download_dir = config.get("TRANSMISSION_DOWNLOAD_DIR", "") @@ -129,25 +133,25 @@ class TransmissionClient(DownloadClient): url = normalize_http_url(config.get("TRANSMISSION_URL", "")) return client == "transmission" and bool(url) - def test_connection(self) -> Tuple[bool, str]: + def test_connection(self) -> tuple[bool, str]: """Test connection to Transmission.""" try: session = self._client.get_session() version = session.version - return True, f"Connected to Transmission {version}" except Exception as e: - return False, f"Connection failed: {str(e)}" + return False, f"Connection failed: {e!s}" + else: + return True, f"Connected to Transmission {version}" def add_download( self, url: str, name: str, - category: Optional[str] = None, - expected_hash: Optional[str] = None, + category: str | None = None, + expected_hash: str | None = None, **kwargs, ) -> str: - """ - Add torrent by URL (magnet or .torrent). + """Add torrent by URL (magnet or .torrent). Args: url: Magnet link or .torrent URL @@ -160,6 +164,7 @@ class TransmissionClient(DownloadClient): Raises: Exception: If adding fails. + """ try: resolved_category = category or self._category or "" @@ -186,7 +191,7 @@ class TransmissionClient(DownloadClient): ) torrent_hash = torrent.hashString.lower() - logger.info(f"Added torrent to Transmission: {torrent_hash}") + logger.info("Added torrent to Transmission: %s", torrent_hash) # Apply per-torrent seeding limits from indexer seed_kwargs = {} @@ -202,23 +207,23 @@ class TransmissionClient(DownloadClient): try: self._client.change_torrent(ids=torrent_hash, **seed_kwargs) except Exception as e: - logger.warning(f"Failed to set seeding limits for {torrent_hash}: {e}") + logger.warning("Failed to set seeding limits for %s: %s", torrent_hash, e) + except Exception: + logger.exception("Transmission add failed") + raise + else: return torrent_hash - except Exception as e: - logger.error(f"Transmission add failed: {e}") - raise - def get_status(self, download_id: str) -> DownloadStatus: - """ - Get torrent status by hash. + """Get torrent status by hash. Args: download_id: Torrent info_hash Returns: Current download status. + """ try: torrent = self._client.get_torrent(download_id) @@ -232,7 +237,9 @@ class TransmissionClient(DownloadClient): # 5: seed pending # 6: seeding # torrent.status is an enum with .value as string - status_value = torrent.status.value if hasattr(torrent.status, 'value') else str(torrent.status) + status_value = ( + torrent.status.value if hasattr(torrent.status, "value") else str(torrent.status) + ) status_map = { "stopped": ("paused", "Paused"), "check pending": ("checking", "Waiting to check"), @@ -246,30 +253,30 @@ class TransmissionClient(DownloadClient): state, message = status_map.get(status_value, ("downloading", "Downloading")) progress = torrent.percent_done * 100 # Only mark complete when seeding - seed pending means files still being moved - complete = progress >= 100 and status_value == "seeding" + complete = progress >= _SEEDING_PROGRESS_PERCENT and status_value == "seeding" if complete: message = "Complete" # Get ETA if available and reasonable (less than 1 week) eta = None - if hasattr(torrent, 'eta') and torrent.eta: + if hasattr(torrent, "eta") and torrent.eta: eta_seconds = torrent.eta.total_seconds() - if 0 < eta_seconds < 604800: + if 0 < eta_seconds < _ETA_MAX_SECONDS: eta = int(eta_seconds) # Get download speed - download_speed = torrent.rate_download if hasattr(torrent, 'rate_download') else None + download_speed = torrent.rate_download if hasattr(torrent, "rate_download") else None # Get file path for completed downloads file_path = None if complete: # Output path is downloadDir + torrent name (with ':' replaced) - torrent_name = getattr(torrent, 'name', '') + torrent_name = getattr(torrent, "name", "") if isinstance(torrent_name, str): - torrent_name = torrent_name.replace(':', '_') + torrent_name = torrent_name.replace(":", "_") file_path = self._build_path( - getattr(torrent, 'download_dir', ''), + getattr(torrent, "download_dir", ""), torrent_name, ) @@ -288,9 +295,8 @@ class TransmissionClient(DownloadClient): except Exception as e: return DownloadStatus.error(self._log_error("get_status", e)) - def remove(self, download_id: str, delete_files: bool = False) -> bool: - """ - Remove a torrent from Transmission. + def remove(self, download_id: str, *, delete_files: bool = False) -> bool: + """Remove a torrent from Transmission. Args: download_id: Torrent info_hash @@ -298,6 +304,7 @@ class TransmissionClient(DownloadClient): Returns: True if successful. + """ try: self._client.remove_torrent( @@ -305,40 +312,42 @@ class TransmissionClient(DownloadClient): delete_data=delete_files, ) logger.info( - f"Removed torrent from Transmission: {download_id}" - + (" (with files)" if delete_files else "") + "Removed torrent from Transmission: %s%s", + download_id, + " (with files)" if delete_files else "", ) - return True except Exception as e: self._log_error("remove", e) return False + else: + return True - def get_download_path(self, download_id: str) -> Optional[str]: - """ - Get the path where torrent files are located. + def get_download_path(self, download_id: str) -> str | None: + """Get the path where torrent files are located. Args: download_id: Torrent info_hash Returns: Content path (file or directory), or None. + """ try: - torrent = self._client.get_torrent(download_id) - torrent_name = getattr(torrent, 'name', '') - if isinstance(torrent_name, str): - torrent_name = torrent_name.replace(':', '_') - return self._build_path( - getattr(torrent, 'download_dir', ''), - torrent_name, - ) + torrent = self._client.get_torrent(download_id) + torrent_name = getattr(torrent, "name", "") + if isinstance(torrent_name, str): + torrent_name = torrent_name.replace(":", "_") + return self._build_path( + getattr(torrent, "download_dir", ""), + torrent_name, + ) except Exception as e: self._log_error("get_download_path", e, level="debug") return None def find_existing( - self, url: str, category: Optional[str] = None - ) -> Optional[Tuple[str, DownloadStatus]]: + self, url: str, category: str | None = None + ) -> tuple[str, DownloadStatus] | None: """Check if a torrent for this URL already exists in Transmission.""" try: torrent_info = extract_torrent_info(url) @@ -348,9 +357,10 @@ class TransmissionClient(DownloadClient): try: self._client.get_torrent(torrent_info.info_hash) status = self.get_status(torrent_info.info_hash) - return (torrent_info.info_hash, status) except KeyError: return None + else: + return (torrent_info.info_hash, status) except Exception as e: - logger.debug(f"Error checking for existing torrent: {e}") + logger.debug("Error checking for existing torrent: %s", e) return None diff --git a/shelfmark/download/fs.py b/shelfmark/download/fs.py index b46441df..191b235e 100644 --- a/shelfmark/download/fs.py +++ b/shelfmark/download/fs.py @@ -11,33 +11,36 @@ import subprocess import tempfile import time from pathlib import Path -from typing import Any, Callable, Optional, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast from shelfmark.core.logger import setup_logger from shelfmark.download.permissions_debug import log_transfer_permission_context +if TYPE_CHECKING: + from collections.abc import Callable + + from gevent.threadpool import ThreadPool + logger = setup_logger(__name__) try: from gevent import monkey as _gevent_monkey from gevent.threadpool import ThreadPool as _GeventThreadPool -except Exception: +except ImportError: _gevent_monkey = None _GeventThreadPool = None T = TypeVar("T") -_IO_THREADPOOL: Optional["_GeventThreadPool"] = None +_IO_THREADPOOL: ThreadPool | None = None def _use_gevent_threadpool() -> bool: return bool( - _gevent_monkey - and _GeventThreadPool - and _gevent_monkey.is_module_patched("threading") + _gevent_monkey and _GeventThreadPool and _gevent_monkey.is_module_patched("threading") ) -def _get_io_threadpool() -> "_GeventThreadPool": +def _get_io_threadpool() -> ThreadPool: global _IO_THREADPOOL if _IO_THREADPOOL is None: pool_size = max(2, min(8, os.cpu_count() or 2)) @@ -45,7 +48,9 @@ def _get_io_threadpool() -> "_GeventThreadPool": return _IO_THREADPOOL -def _call_and_capture(func: Callable[..., T], args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[bool, T | Exception]: +def _call_and_capture[T]( + func: Callable[..., T], args: tuple[Any, ...], kwargs: dict[str, Any] +) -> tuple[bool, T | Exception]: try: return True, func(*args, **kwargs) except Exception as exc: @@ -60,13 +65,10 @@ def _must_avoid_gevent_threadpool(func: Callable[..., Any]) -> bool: # gevent.subprocess requires child watchers on the default event loop. # Executing patched subprocess functions in a worker thread can raise: # "TypeError: child watchers are only available on the default loop". - if _gevent_monkey.is_object_patched("subprocess", "run") and func is subprocess.run: - return True - - return False + return _gevent_monkey.is_object_patched("subprocess", "run") and func is subprocess.run -def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: +def run_blocking_io[T](func: Callable[..., T], *args: Any, **kwargs: Any) -> T: """Run blocking I/O in a native thread when under gevent. gevent's threadpool will eagerly log exceptions raised inside worker threads, @@ -80,13 +82,12 @@ def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: if _use_gevent_threadpool(): ok, result = _get_io_threadpool().apply(_call_and_capture, (func, args, kwargs)) if ok: - return cast(T, result) - exc = cast(Exception, result) + return cast("T", result) + exc = cast("Exception", result) raise exc return func(*args, **kwargs) - _VERIFY_IO_WAIT_SECONDS = 3.0 _PUBLISH_VERIFY_RETRY_SECONDS = 0.25 @@ -107,14 +108,17 @@ def _verify_transfer_size( return logger.debug( - f"File {action} size mismatch, waiting for filesystem sync: {dest} " - f"({actual_size} != {expected_size})" + "File %s size mismatch, waiting for filesystem sync: %s (%s != %s)", + action, + dest, + actual_size, + expected_size, ) time.sleep(_VERIFY_IO_WAIT_SECONDS) actual_size = run_blocking_io(dest.stat).st_size if actual_size != expected_size: - raise IOError( + raise OSError( f"File {action} incomplete, data loss may have occurred. " f"'{dest}' was {actual_size} bytes instead of expected {expected_size}." ) @@ -138,10 +142,11 @@ def _verify_published_file( """ try: _verify_transfer_size(dest, expected_size, action) - return except OSError as error: if not _is_stale_handle_error(error): raise + else: + return time.sleep(_PUBLISH_VERIFY_RETRY_SECONDS) @@ -174,6 +179,7 @@ def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path: Raises: RuntimeError: If no unique path found after max_attempts + """ base = dest_path.stem ext = dest_path.suffix @@ -194,10 +200,11 @@ def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path: finally: run_blocking_io(os.close, fd) if attempt > 0: - logger.info(f"File collision resolved: {try_path.name}") - return try_path + logger.info("File collision resolved: %s", try_path.name) except FileExistsError: continue + else: + return try_path raise RuntimeError(f"Could not write file after {max_attempts} attempts: {dest_path}") @@ -219,7 +226,7 @@ def _system_op(op: str, source: Path, dest: Path) -> None: ) -def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None: +def _perform_nfs_fallback(source: Path, dest: Path, *, is_move: bool) -> None: """Handle NFS/SMB permission errors by falling back to copyfile -> system op.""" expected_size = run_blocking_io(source.stat).st_size @@ -230,15 +237,16 @@ def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None: if is_move: run_blocking_io(source.unlink) - return except Exception as copy_error: # Clean up failed copy attempt if it exists run_blocking_io(dest.unlink, missing_ok=True) if _is_permission_error(copy_error): - log_transfer_permission_context("nfs_fallback_copyfile", source=source, dest=dest, error=copy_error) - logger.error("Fallback copyfile failed (%s -> %s): %s", source, dest, copy_error) + log_transfer_permission_context( + "nfs_fallback_copyfile", source=source, dest=dest, error=copy_error + ) + logger.exception("Fallback copyfile failed (%s -> %s)", source, dest) # Fallback 2: system command op = "mv" if is_move else "cp" @@ -250,10 +258,14 @@ def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None: if is_move: run_blocking_io(source.unlink, missing_ok=True) except subprocess.CalledProcessError as sys_error: - log_transfer_permission_context("nfs_fallback_system", source=source, dest=dest, error=sys_error) - logger.error("System %s failed (%s -> %s): %s", op, source, dest, sys_error.stderr) + log_transfer_permission_context( + "nfs_fallback_system", source=source, dest=dest, error=sys_error + ) + logger.exception("System %s failed (%s -> %s): %s", op, source, dest, sys_error.stderr) run_blocking_io(dest.unlink, missing_ok=True) raise + else: + return def _is_enoent_error(error: Exception) -> bool: @@ -263,7 +275,7 @@ def _is_enoent_error(error: Exception) -> bool: def _can_use_partial_copy_after_enoent( - temp_path: Optional[Path], + temp_path: Path | None, expected_size: int, action: str, ) -> bool: @@ -273,9 +285,10 @@ def _can_use_partial_copy_after_enoent( try: _verify_transfer_size(temp_path, expected_size, action) - return True - except Exception: + except OSError: return False + else: + return True def _claim_destination(path: Path) -> bool: @@ -345,7 +358,6 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool: run_blocking_io(os.close, fd) except OSError: pass - return True except Exception as e: if _is_permission_error(e): log_transfer_permission_context( @@ -356,6 +368,8 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool: ) run_blocking_io(dest_path.unlink, missing_ok=True) raise + else: + return True def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path: @@ -378,6 +392,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Raises: RuntimeError: If no unique path found after max_attempts + """ base = dest_path.stem ext = dest_path.suffix @@ -402,8 +417,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> else: run_blocking_io(os.rename, str(source_path), str(try_path)) if attempt > 0: - logger.info(f"File collision resolved: {try_path.name}") - return try_path + logger.info("File collision resolved: %s", try_path.name) except FileExistsError: # Race condition: file created between exists() check and rename() if claimed: @@ -421,7 +435,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> run_blocking_io(try_path.unlink, missing_ok=True) claimed = False - temp_path: Optional[Path] = None + temp_path: Path | None = None try: try: temp_path = _create_temp_path(try_path) @@ -464,9 +478,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> run_blocking_io(source_path.unlink) if attempt > 0: - logger.info(f"File collision resolved: {try_path.name}") - return try_path - + logger.info("File collision resolved: %s", try_path.name) except FileExistsError: if temp_path: run_blocking_io(temp_path.unlink, missing_ok=True) @@ -475,6 +487,8 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> if temp_path: run_blocking_io(temp_path.unlink, missing_ok=True) raise + else: + return try_path except (PermissionError, OSError) as e: if _is_permission_error(e): @@ -493,17 +507,19 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> try: _perform_nfs_fallback(source_path, try_path, is_move=True) if attempt > 0: - logger.info(f"File collision resolved (fallback): {try_path.name}") - return try_path + logger.info("File collision resolved (fallback): %s", try_path.name) except Exception as fallback_error: - logger.error( - "NFS fallback also failed (%s -> %s): %s", + logger.exception( + "NFS fallback also failed (%s -> %s)", source_path, try_path, - fallback_error, ) raise e from fallback_error + else: + return try_path raise + else: + return try_path raise RuntimeError(f"Could not move file after {max_attempts} attempts: {dest_path}") @@ -521,6 +537,7 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100) Raises: RuntimeError: If no unique path found after max_attempts + """ base = dest_path.stem ext = dest_path.suffix @@ -531,8 +548,7 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100) try: run_blocking_io(os.link, str(source_path), str(try_path)) if attempt > 0: - logger.info(f"File collision resolved: {try_path.name}") - return try_path + logger.info("File collision resolved: %s", try_path.name) except FileExistsError: continue except OSError as e: @@ -553,6 +569,8 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100) ) return atomic_copy(source_path, dest_path, max_attempts=max_attempts) raise + else: + return try_path raise RuntimeError(f"Could not create hardlink after {max_attempts} attempts: {dest_path}") @@ -573,6 +591,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Raises: RuntimeError: If no unique path found after max_attempts + """ base = dest_path.stem ext = dest_path.suffix @@ -583,7 +602,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}" if run_blocking_io(try_path.exists): continue - temp_path: Optional[Path] = None + temp_path: Path | None = None try: temp_path = _create_temp_path(try_path) try: @@ -606,11 +625,10 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> try: _perform_nfs_fallback(source_path, temp_path, is_move=False) except Exception as fallback_error: - logger.error( - "NFS fallback also failed (%s -> %s): %s", + logger.exception( + "NFS fallback also failed (%s -> %s)", source_path, temp_path, - fallback_error, ) raise e from fallback_error elif _is_enoent_error(e) and _can_use_partial_copy_after_enoent( @@ -639,11 +657,12 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> raise if attempt > 0: - logger.info(f"File collision resolved: {try_path.name}") - return try_path + logger.info("File collision resolved: %s", try_path.name) except Exception: if temp_path: run_blocking_io(temp_path.unlink, missing_ok=True) raise + else: + return try_path raise RuntimeError(f"Could not copy file after {max_attempts} attempts: {dest_path}") diff --git a/shelfmark/download/http.py b/shelfmark/download/http.py index 3cf901e4..f015a3f2 100644 --- a/shelfmark/download/http.py +++ b/shelfmark/download/http.py @@ -2,54 +2,76 @@ import random import time +from http import HTTPStatus from io import BytesIO from threading import Event, Thread -from typing import Callable, Optional -from urllib.parse import urlparse, urljoin +from typing import TYPE_CHECKING, NoReturn +from urllib.parse import urljoin, urlparse import requests from tqdm import tqdm -from shelfmark.download import network -from shelfmark.download.network import get_proxies, get_ssl_verify from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger +from shelfmark.download import network +from shelfmark.download.network import get_proxies, get_ssl_verify + +if TYPE_CHECKING: + from collections.abc import Callable + from types import ModuleType logger = setup_logger(__name__) +_MAX_REDIRECTS = 5 +_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN +_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND +_HTTP_STATUS_RATE_LIMITED = HTTPStatus.TOO_MANY_REQUESTS +_HTTP_STATUS_OK = HTTPStatus.OK +_HTTP_STATUS_RANGE_NOT_SATISFIABLE = HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE +_HTTP_STATUS_PARTIAL_CONTENT = HTTPStatus.PARTIAL_CONTENT +_HTTP_STATUS_NON_RETRYABLE = (_HTTP_STATUS_FORBIDDEN, _HTTP_STATUS_NOT_FOUND) + # Bypasser modules are imported lazily to support dynamic selection based on config _internal_bypasser = None _external_bypasser = None -def _get_internal_bypasser(): +def _raise_too_many_redirects(message: str) -> NoReturn: + raise requests.exceptions.TooManyRedirects(message) + + +def _get_internal_bypasser() -> ModuleType: """Lazy import of internal bypasser module.""" global _internal_bypasser if _internal_bypasser is None: try: from shelfmark.bypass import internal_bypasser + _internal_bypasser = internal_bypasser except ImportError as e: - raise RuntimeError( + msg = ( f"Failed to import internal bypasser: {e}. " "Check that all dependencies are installed. " "You may need to disable CF bypass or use the external bypasser." - ) from e + ) + raise RuntimeError(msg) from e return _internal_bypasser -def _get_external_bypasser(): +def _get_external_bypasser() -> ModuleType: """Lazy import of external bypasser module.""" global _external_bypasser if _external_bypasser is None: try: from shelfmark.bypass import external_bypasser + _external_bypasser = external_bypasser except ImportError as e: - raise RuntimeError( + msg = ( f"Failed to import external bypasser: {e}. " "Check that the external bypasser is properly configured." - ) from e + ) + raise RuntimeError(msg) from e return _external_bypasser @@ -63,25 +85,29 @@ def _is_cf_bypass_enabled() -> bool: return app_config.get("USE_CF_BYPASS", True) -def get_bypassed_page(url, selector=None, cancel_flag=None): +def get_bypassed_page( + url: str, + selector: network.AAMirrorSelector | None = None, + cancel_flag: Event | None = None, +) -> str | None: """Wrapper that delegates to the appropriate bypasser based on config.""" if _is_using_external_bypasser(): return _get_external_bypasser().get_bypassed_page(url, selector, cancel_flag) return _get_internal_bypasser().get_bypassed_page(url, selector, cancel_flag) -def get_cf_cookies_for_domain(domain): +def get_cf_cookies_for_domain(domain: str) -> dict[str, str]: """Get CF cookies - only available with internal bypasser.""" if _is_using_external_bypasser(): - logger.debug(f"External bypasser in use, CF cookies not available for {domain}") + logger.debug("External bypasser in use, CF cookies not available for %s", domain) return {} return _get_internal_bypasser().get_cf_cookies_for_domain(domain) -def get_cf_user_agent_for_domain(domain): +def get_cf_user_agent_for_domain(domain: str) -> str | None: """Get CF user agent - only available with internal bypasser.""" if _is_using_external_bypasser(): - logger.debug(f"External bypasser in use, CF user agent not available for {domain}") + logger.debug("External bypasser in use, CF user agent not available for %s", domain) return None return _get_internal_bypasser().get_cf_user_agent_for_domain(domain) @@ -100,7 +126,7 @@ def _apply_cf_bypass(url: str, headers: dict) -> dict: cookies = get_cf_cookies_for_domain(hostname) stored_ua = get_cf_user_agent_for_domain(hostname) if stored_ua: - headers['User-Agent'] = stored_ua + headers["User-Agent"] = stored_ua return cookies @@ -110,19 +136,22 @@ MAX_DOWNLOAD_RETRIES = 2 MAX_RESUME_ATTEMPTS = 3 RETRYABLE_CODES = (429, 500, 502, 503, 504) -CONNECTION_ERRORS = (requests.exceptions.ConnectionError, requests.exceptions.Timeout, - requests.exceptions.SSLError, requests.exceptions.ChunkedEncodingError) +CONNECTION_ERRORS = ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.SSLError, + requests.exceptions.ChunkedEncodingError, +) DOWNLOAD_HEADERS = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - 'Accept-Encoding': 'gzip, deflate, br', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1', + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", } -def parse_size_string(size: str) -> Optional[float]: +def parse_size_string(size: str) -> float | None: """Parse a human-readable size string (e.g., '10.5 MB') into bytes.""" if not size: return None @@ -133,20 +162,22 @@ def parse_size_string(size: str) -> Optional[float]: if normalized.endswith(suffix): return float(normalized[:-2]) * mult return float(normalized) - except (ValueError, IndexError): + except ValueError, IndexError: return None + def _backoff_delay(attempt: int, base: float = 0.25, cap: float = 3.0) -> float: """Exponential backoff with jitter.""" return min(cap, base * (2 ** (attempt - 1))) + random.random() * base -def _get_status_code(e: Exception) -> Optional[int]: +def _get_status_code(e: Exception) -> int | None: """Extract HTTP status code from an exception, or None if not applicable.""" if isinstance(e, requests.exceptions.HTTPError) and e.response is not None: return e.response.status_code return None + def _is_retryable_error(e: Exception) -> bool: """Check if error is retryable (connection error or retryable HTTP status).""" if isinstance(e, CONNECTION_ERRORS): @@ -155,31 +186,34 @@ def _is_retryable_error(e: Exception) -> bool: return status is not None and status in RETRYABLE_CODES -def _try_rotation(original_url: str, current_url: str, selector: network.AAMirrorSelector) -> Optional[str]: +def _try_rotation( + original_url: str, current_url: str, selector: network.AAMirrorSelector +) -> str | None: """Try mirror/DNS rotation. Returns new URL or None.""" if current_url.startswith(network.get_aa_base_url()): new_base, action = selector.next_mirror_or_rotate_dns() if action in ("mirror", "dns") and new_base: new_url = selector.rewrite(original_url) - logger.info(f"[{action}] switching to: {new_url}") + logger.info("[%s] switching to: %s", action, new_url) return new_url elif network.should_rotate_dns_for_url(current_url) and network.rotate_dns_provider(): - logger.info(f"[dns-rotate] retrying: {original_url}") + logger.info("[dns-rotate] retrying: %s", original_url) return original_url return None def html_get_page( url: str, - retry: Optional[int] = None, + retry: int | None = None, + selector: network.AAMirrorSelector | None = None, + cancel_flag: Event | None = None, + status_callback: Callable[[str, str | None], None] | None = None, + *, use_bypasser: bool = False, - selector: Optional[network.AAMirrorSelector] = None, - cancel_flag: Optional[Event] = None, - status_callback: Optional[Callable[[str, Optional[str]], None]] = None, allow_bypasser_fallback: bool = True, include_response_url: bool = False, success_delay: float = 1.0, - session: Optional[requests.Session] = None, + session: requests.Session | None = None, ) -> str | tuple[str, str]: """Fetch HTML content from a URL with retry mechanism. @@ -189,7 +223,9 @@ def html_get_page( include_response_url: If True, return `(html, final_url)` to expose the resolved response URL after redirects. success_delay: Optional delay (seconds) after successful fetch. + """ + def _result(html: str, response_url: str) -> str | tuple[str, str]: if include_response_url: return html, response_url @@ -204,41 +240,43 @@ def html_get_page( for attempt in range(1, retry + 1): # Check for cancellation before each attempt if cancel_flag and cancel_flag.is_set(): - logger.info(f"html_get_page cancelled before attempt {attempt}") + logger.info("html_get_page cancelled before attempt %s", attempt) return _result("", current_url) try: if use_bypasser_now and _is_cf_bypass_enabled(): - logger.debug(f"GET (bypasser): {current_url}") if status_callback: status_callback("resolving", "Bypassing protection...") heartbeat_stop = Event() - heartbeat_thread: Optional[Thread] = None + heartbeat_thread: Thread | None = None if status_callback: - def _heartbeat() -> None: + + def _heartbeat(stop_event: Event = heartbeat_stop) -> None: # Keep the download "alive" during long bypass operations so the orchestrator # doesn't flag it as stalled. - while not heartbeat_stop.wait(timeout=30): - if cancel_flag and cancel_flag.is_set(): - return - try: - status_callback("resolving", "Bypassing protection...") - except Exception: - return - heartbeat_thread = Thread(target=_heartbeat, daemon=True, name="BypassHeartbeat") + if cancel_flag and cancel_flag.is_set(): + return + try: + status_callback("resolving", "Bypassing protection...") + except Exception: + return + + heartbeat_thread = Thread( + target=_heartbeat, daemon=True, name="BypassHeartbeat" + ) heartbeat_thread.start() try: result = get_bypassed_page(current_url, selector, cancel_flag) return _result(result or "", current_url) except Exception as e: - logger.warning(f"Bypasser error: {type(e).__name__}: {e}") + logger.warning("Bypasser error: %s: %s", type(e).__name__, e) return _result("", current_url) finally: heartbeat_stop.set() if heartbeat_thread: heartbeat_thread.join(timeout=1) - logger.debug(f"GET: {current_url}") + logger.debug("GET: %s", current_url) # Use a browser-like UA by default (AA can behave differently for python-requests UA). headers = {"User-Agent": DOWNLOAD_HEADERS["User-Agent"]} @@ -267,7 +305,9 @@ def html_get_page( if is_aa_url and response.is_redirect: location = response.headers.get("Location", "") if not location: - raise requests.exceptions.TooManyRedirects(f"Redirect with no Location header: {current_url}") + _raise_too_many_redirects( + f"Redirect with no Location header: {current_url}" + ) redirect_url = urljoin(current_url, location) current_host = urlparse(current_url).hostname or "" @@ -305,8 +345,8 @@ def html_get_page( # Same-host redirect (relative or absolute) - follow manually. redirects_followed += 1 - if redirects_followed > 5: - raise requests.exceptions.TooManyRedirects(f"Too many redirects for {current_url}") + if redirects_followed > _MAX_REDIRECTS: + _raise_too_many_redirects(f"Too many redirects for {current_url}") current_url = redirect_url continue @@ -319,14 +359,14 @@ def html_get_page( status = _get_status_code(e) # 403 = Cloudflare/DDoS-Guard protection - if status == 403: + if status == _HTTP_STATUS_FORBIDDEN: # If bypasser fallback is disabled, try mirrors instead if not allow_bypasser_fallback: new_url = _try_rotation(original_url, current_url, selector) if new_url: current_url = new_url continue - logger.warning(f"403 error, mirrors exhausted: {current_url}") + logger.warning("403 error, mirrors exhausted: %s", current_url) return _result("", current_url) if _is_cf_bypass_enabled() and not use_bypasser_now: @@ -336,19 +376,22 @@ def html_get_page( fresh_cookies = get_cf_cookies_for_domain(parsed.hostname or "") if fresh_cookies and not cookies: # Cookies are now available - retry with cookies before using bypasser - logger.debug(f"403 but cookies now available - retrying with cookies: {current_url}") + logger.debug( + "403 but cookies now available - retrying with cookies: %s", + current_url, + ) continue - logger.info(f"403 detected; switching to bypasser: {current_url}") + logger.info("403 detected; switching to bypasser: %s", current_url) if status_callback: status_callback("resolving", "Bypassing protection...") use_bypasser_now = True continue - logger.warning(f"403 error, giving up: {current_url}") + logger.warning("403 error, giving up: %s", current_url) return _result("", current_url) # 404 = Not found - if status == 404: - logger.warning(f"404 error: {current_url}") + if status == _HTTP_STATUS_NOT_FOUND: + logger.warning("404 error: %s", current_url) return _result("", current_url) # Try mirror/DNS rotation on retryable errors @@ -360,10 +403,17 @@ def html_get_page( # Retry with backoff if attempt < retry: - logger.warning(f"Retry {attempt}/{retry} for {current_url}: {type(e).__name__}: {e}") + logger.warning( + "Retry %s/%s for %s: %s: %s", + attempt, + retry, + current_url, + type(e).__name__, + e, + ) time.sleep(_backoff_delay(attempt)) else: - logger.error(f"Giving up after {retry} attempts: {current_url}") + logger.exception("Giving up after %s attempts: %s", retry, current_url) return _result("", current_url) @@ -371,12 +421,12 @@ def html_get_page( def download_url( link: str, size: str = "", - progress_callback: Optional[Callable[[float], None]] = None, - cancel_flag: Optional[Event] = None, - _selector: Optional[network.AAMirrorSelector] = None, - status_callback: Optional[Callable[[str, Optional[str]], None]] = None, - referer: Optional[str] = None, -) -> Optional[BytesIO]: + progress_callback: Callable[[float], None] | None = None, + cancel_flag: Event | None = None, + _selector: network.AAMirrorSelector | None = None, + status_callback: Callable[[str, str | None], None] | None = None, + referer: str | None = None, +) -> BytesIO | None: """Download content from URL with automatic retry and resume support.""" selector = _selector or network.AAMirrorSelector() current_url = selector.rewrite(link) @@ -384,7 +434,7 @@ def download_url( # Build headers with optional referer headers = DOWNLOAD_HEADERS.copy() if referer: - headers['Referer'] = referer + headers["Referer"] = referer total_size = parse_size_string(size) or 0 attempt = 0 @@ -399,19 +449,35 @@ def download_url( try: if attempt > 0 and status_callback: - status_callback("resolving", f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})") + status_callback( + "resolving", + f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})", + ) - logger.info(f"Downloading: {current_url} (attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})") + logger.info( + "Downloading: %s (attempt %s/%s)", + current_url, + attempt + 1, + MAX_DOWNLOAD_RETRIES, + ) # Try with CF cookies/UA if available cookies = _apply_cf_bypass(current_url, headers) - response = requests.get(current_url, stream=True, proxies=get_proxies(current_url), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers, verify=get_ssl_verify(current_url)) + response = requests.get( + current_url, + stream=True, + proxies=get_proxies(current_url), + timeout=REQUEST_TIMEOUT, + cookies=cookies, + headers=headers, + verify=get_ssl_verify(current_url), + ) response.raise_for_status() if status_callback: status_callback("downloading", "") - total_size = total_size or float(response.headers.get('content-length', 0)) - pbar = tqdm(total=total_size, unit='B', unit_scale=True, desc='Downloading') + total_size = total_size or float(response.headers.get("content-length", 0)) + pbar = tqdm(total=total_size, unit="B", unit_scale=True, desc="Downloading") for chunk in response.iter_content(chunk_size=8192): if chunk: @@ -426,55 +492,69 @@ def download_url( pbar.close() # Validate - check we didn't get HTML instead of file - if total_size > 0 and bytes_downloaded < total_size * 0.9: - if response.headers.get('content-type', '').startswith('text/html'): - logger.warning(f"Received HTML instead of file: {current_url}") - return None + if ( + total_size > 0 + and bytes_downloaded < total_size * 0.9 + and response.headers.get("content-type", "").startswith("text/html") + ): + logger.warning("Received HTML instead of file: %s", current_url) + return None - logger.debug(f"Download completed: {bytes_downloaded} bytes") - return buffer + logger.debug("Download completed: %s bytes", bytes_downloaded) except requests.exceptions.RequestException as e: status = _get_status_code(e) retryable = _is_retryable_error(e) # Z-Library 403 - try refreshing cookies via bypasser once before giving up - if status == 403 and _is_cf_bypass_enabled() and not zlib_cookie_refresh_attempted: + if ( + status == _HTTP_STATUS_FORBIDDEN + and _is_cf_bypass_enabled() + and not zlib_cookie_refresh_attempted + ): parsed = urlparse(current_url) - if parsed.hostname and 'z-lib' in parsed.hostname and referer: + if parsed.hostname and "z-lib" in parsed.hostname and referer: zlib_cookie_refresh_attempted = True - logger.info(f"Z-Library 403 - refreshing cookies via referer: {referer}") + logger.info("Z-Library 403 - refreshing cookies via referer: %s", referer) try: get_bypassed_page(referer, selector, cancel_flag) time.sleep(0.5) # Retry with fresh cookies (don't increment attempt) continue except Exception as cookie_err: - logger.warning(f"Z-Library cookie refresh failed: {cookie_err}") + logger.warning("Z-Library cookie refresh failed: %s", cookie_err) # Non-retryable errors - if status in (403, 404): - logger.warning(f"Download failed ({status}): {current_url}") + if status in _HTTP_STATUS_NON_RETRYABLE: + logger.warning("Download failed (%s): %s", status, current_url) return None # Rate limited - skip to next source immediately # (waiting doesn't help with concurrent downloads hitting the same server) - if status == 429: - logger.info(f"Rate limited (429) - trying next source") + if status == _HTTP_STATUS_RATE_LIMITED: + logger.info("Rate limited (429) - trying next source") if status_callback: status_callback("resolving", "Server busy, trying next") return None # Timeout - don't retry, server likely overloaded if isinstance(e, requests.exceptions.Timeout): - logger.warning(f"Timeout: {current_url} - skipping to next source") + logger.warning("Timeout: %s - skipping to next source", current_url) if status_callback: status_callback("resolving", "Server timed out, trying next") return None # Try to resume if we got some data if bytes_downloaded > 0 and retryable: - resumed = _try_resume(current_url, buffer, bytes_downloaded, total_size, progress_callback, cancel_flag, headers) + resumed = _try_resume( + current_url, + buffer, + bytes_downloaded, + total_size, + progress_callback, + cancel_flag, + headers, + ) if resumed: return resumed @@ -486,12 +566,14 @@ def download_url( attempt += 1 continue - logger.warning(f"Download error: {type(e).__name__}: {e}") + logger.warning("Download error: %s: %s", type(e).__name__, e) if attempt < MAX_DOWNLOAD_RETRIES - 1: time.sleep(_backoff_delay(attempt + 1)) attempt += 1 + else: + return buffer - logger.error(f"Download failed after {MAX_DOWNLOAD_RETRIES} attempts: {link}") + logger.error("Download failed after %s attempts: %s", MAX_DOWNLOAD_RETRIES, link) return None @@ -500,35 +582,54 @@ def _try_resume( buffer: BytesIO, start_byte: int, total_size: float, - progress_callback: Optional[Callable[[float], None]], - cancel_flag: Optional[Event], - base_headers: Optional[dict] = None, -) -> Optional[BytesIO]: + progress_callback: Callable[[float], None] | None, + cancel_flag: Event | None, + base_headers: dict | None = None, +) -> BytesIO | None: """Try to resume an interrupted download.""" for attempt in range(MAX_RESUME_ATTEMPTS): - logger.info(f"Resuming from {start_byte} bytes (attempt {attempt + 1}/{MAX_RESUME_ATTEMPTS})") + logger.info( + "Resuming from %s bytes (attempt %s/%s)", + start_byte, + attempt + 1, + MAX_RESUME_ATTEMPTS, + ) time.sleep(_backoff_delay(attempt + 1, base=0.5, cap=5.0)) try: # Try with CF cookies/UA if available - resume_headers = {**(base_headers or DOWNLOAD_HEADERS), 'Range': f'bytes={start_byte}-'} + resume_headers = { + **(base_headers or DOWNLOAD_HEADERS), + "Range": f"bytes={start_byte}-", + } cookies = _apply_cf_bypass(url, resume_headers) response = requests.get( - url, stream=True, proxies=get_proxies(url), timeout=REQUEST_TIMEOUT, - headers=resume_headers, cookies=cookies, verify=get_ssl_verify(url) + url, + stream=True, + proxies=get_proxies(url), + timeout=REQUEST_TIMEOUT, + headers=resume_headers, + cookies=cookies, + verify=get_ssl_verify(url), ) - + # Check resume support - if response.status_code == 200: # Server doesn't support resume + if response.status_code == _HTTP_STATUS_OK: # Server doesn't support resume logger.info("Server doesn't support resume") return None - if response.status_code == 416: # Range not satisfiable + if response.status_code == _HTTP_STATUS_RANGE_NOT_SATISFIABLE: # Range not satisfiable logger.warning("Range not satisfiable") return None - if response.status_code != 206: + if response.status_code != _HTTP_STATUS_PARTIAL_CONTENT: response.raise_for_status() - - pbar = tqdm(total=total_size, initial=start_byte, unit='B', unit_scale=True, desc='Resuming') + + pbar = tqdm( + total=total_size, + initial=start_byte, + unit="B", + unit_scale=True, + desc="Resuming", + ) for chunk in response.iter_content(chunk_size=8192): if chunk: buffer.write(chunk) @@ -540,14 +641,15 @@ def _try_resume( pbar.close() return None pbar.close() - - logger.info(f"Resume completed: {start_byte} bytes") - return buffer - + + logger.info("Resume completed: %s bytes", start_byte) + except requests.exceptions.RequestException as e: - logger.debug(f"Resume attempt {attempt + 1} failed: {e}") - - logger.warning(f"Resume failed after {MAX_RESUME_ATTEMPTS} attempts") + logger.debug("Resume attempt %s failed: %s", attempt + 1, e) + else: + return buffer + + logger.warning("Resume failed after %s attempts", MAX_RESUME_ATTEMPTS) return None diff --git a/shelfmark/download/network.py b/shelfmark/download/network.py index 629c7424..a848cda9 100644 --- a/shelfmark/download/network.py +++ b/shelfmark/download/network.py @@ -1,22 +1,26 @@ """DNS rotation, mirror selection, and network utilities.""" import fnmatch -import requests -import urllib.request -from typing import Sequence, Tuple, Any, Union, cast, List, Optional, Callable -import socket -import dns.resolver -from socket import AddressFamily, SocketKind -import urllib.parse import ipaddress - -from shelfmark.core.logger import setup_logger -from shelfmark.core.config import config as app_config -from shelfmark.core.utils import normalize_http_url +import socket +import urllib.parse +import urllib.request from datetime import datetime, timedelta +from socket import AddressFamily, SocketKind +from typing import TYPE_CHECKING, Any, cast + +import dns.resolver +import requests + +from shelfmark.core.config import config as app_config +from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import normalize_http_url + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence -def _get_no_proxy_patterns() -> List[str]: +def _get_no_proxy_patterns() -> list[str]: """Get list of NO_PROXY patterns from config.""" no_proxy = app_config.get("NO_PROXY", "") if not no_proxy: @@ -43,19 +47,14 @@ def should_bypass_proxy(url: str) -> bool: try: parsed = urllib.parse.urlparse(url) hostname = (parsed.hostname or "").lower() - except Exception as e: - logger.debug(f"Failed to parse URL for proxy bypass check: {url} - {e}") + except ValueError as e: + logger.debug("Failed to parse URL for proxy bypass check: %s - %s", url, e) return False if not hostname: return False - for pattern in patterns: - # Use fnmatch for wildcard matching (supports * and ?) - if fnmatch.fnmatch(hostname, pattern): - return True - - return False + return any(fnmatch.fnmatch(hostname, pattern) for pattern in patterns) def get_proxies(url: str = "") -> dict: @@ -64,6 +63,7 @@ def get_proxies(url: str = "") -> dict: Args: url: Optional URL to check against NO_PROXY patterns. If provided and matches a pattern, returns empty dict. + """ # Check NO_PROXY bypass first if url and should_bypass_proxy(url): @@ -110,7 +110,7 @@ def get_ssl_verify(url: str = "") -> bool: hostname = parsed.hostname or "" if hostname and _is_local_address(hostname): return False - except Exception: + except ValueError: pass return True @@ -128,7 +128,7 @@ def _apply_ssl_warning_suppression() -> None: default case is a complete no-op (zero behavioural change for users who never touch the setting). """ - global _ssl_warnings_suppressed # noqa: PLW0603 + global _ssl_warnings_suppressed import urllib3 mode = app_config.get("CERTIFICATE_VALIDATION", "enabled") @@ -138,6 +138,7 @@ def _apply_ssl_warning_suppression() -> None: logger.debug("SSL warnings suppressed (certificate validation: %s)", mode) elif _ssl_warnings_suppressed: import warnings + warnings.simplefilter("default", urllib3.exceptions.InsecureRequestWarning) _ssl_warnings_suppressed = False logger.debug("SSL warnings restored (certificate validation: enabled)") @@ -145,20 +146,37 @@ def _apply_ssl_warning_suppression() -> None: # DNS state - authoritative values managed by this module # Other modules should use get_dns_config() to read these -CUSTOM_DNS: List[str] = [] +CUSTOM_DNS: list[str] = [] DOH_SERVER: str = "" # Try to use gevent locks if available (for gevent worker compatibility) # Fall back to threading locks for non-gevent environments try: from gevent.lock import RLock as _RLock + _using_gevent_locks = True except ImportError: from threading import RLock as _RLock + _using_gevent_locks = False logger = setup_logger(__name__) + +def _call_dns_rotation_callback( + callback: Callable[[str, list[str], str], None], + provider_name: str, + servers: list[str], + doh_url: str, +) -> None: + """Call one DNS rotation callback and log failures.""" + try: + logger.debug("Calling DNS rotation callback: %s", callback.__name__) + callback(provider_name, servers, doh_url) + except Exception as e: + logger.warning("DNS rotation callback %s failed: %s", callback.__name__, e) + + # In-memory state (no disk persistence) STATE_TTL_DAYS = 30 _initialized = False @@ -173,11 +191,11 @@ _dns_switch_lock = _RLock() # DNS rotation callbacks - called when DNS provider switches in auto mode # Callbacks receive (provider_name: str, servers: List[str], doh_url: str) -_dns_rotation_callbacks: List[Callable[[str, List[str], str], None]] = [] +_dns_rotation_callbacks: list[Callable[[str, list[str], str], None]] = [] _dns_callback_lock = _RLock() -def register_dns_rotation_callback(callback: Callable[[str, List[str], str], None]) -> None: +def register_dns_rotation_callback(callback: Callable[[str, list[str], str], None]) -> None: """Register a callback to be called when DNS provider rotates. The callback receives (provider_name, servers, doh_url) as arguments. @@ -186,51 +204,50 @@ def register_dns_rotation_callback(callback: Callable[[str, List[str], str], Non with _dns_callback_lock: if callback not in _dns_rotation_callbacks: _dns_rotation_callbacks.append(callback) - logger.debug(f"Registered DNS rotation callback: {callback.__name__}") + logger.debug("Registered DNS rotation callback: %s", callback.__name__) -def unregister_dns_rotation_callback(callback: Callable[[str, List[str], str], None]) -> None: +def unregister_dns_rotation_callback(callback: Callable[[str, list[str], str], None]) -> None: """Unregister a previously registered DNS rotation callback.""" with _dns_callback_lock: if callback in _dns_rotation_callbacks: _dns_rotation_callbacks.remove(callback) - logger.debug(f"Unregistered DNS rotation callback: {callback.__name__}") + logger.debug("Unregistered DNS rotation callback: %s", callback.__name__) -def _notify_dns_rotation(provider_name: str, servers: List[str], doh_url: str) -> None: +def _notify_dns_rotation(provider_name: str, servers: list[str], doh_url: str) -> None: """Notify all registered callbacks about DNS rotation.""" with _dns_callback_lock: callbacks = _dns_rotation_callbacks.copy() for callback in callbacks: - try: - logger.debug(f"Calling DNS rotation callback: {callback.__name__}") - callback(provider_name, servers, doh_url) - except Exception as e: - logger.warning(f"DNS rotation callback {callback.__name__} failed: {e}") + _call_dns_rotation_callback(callback, provider_name, servers, doh_url) -def _load_state(): +def _load_state() -> dict[str, Any]: """Return current in-memory network state (no disk persistence).""" - if state.get('chosen_at'): - chosen = datetime.fromisoformat(state['chosen_at']) + if state.get("chosen_at"): + chosen = datetime.fromisoformat(state["chosen_at"]) if datetime.now() - chosen > timedelta(days=STATE_TTL_DAYS): state.clear() return state -def _save_state(aa_url=None, dns_provider=None): + +def _save_state(aa_url: str | None = None, dns_provider: str | None = None) -> None: """Update in-memory network state (no disk persistence).""" if aa_url: - state['aa_base_url'] = aa_url + state["aa_base_url"] = aa_url if dns_provider: - state['dns_provider'] = dns_provider - state['chosen_at'] = datetime.now().isoformat() + state["dns_provider"] = dns_provider + state["chosen_at"] = datetime.now().isoformat() + # AA URL failover state _current_aa_url_index = 0 -_aa_urls: List[str] = [] # Initialized lazily in _initialize_aa_state() +_aa_urls: list[str] = [] # Initialized lazily in _initialize_aa_state() _aa_base_url: str = "" # Current active AA URL + def _ensure_initialized() -> None: """Lazy guard so runtime setup happens once and late calls still work.""" global _initialized @@ -241,6 +258,7 @@ def _ensure_initialized() -> None: if not _initialized: init() + # DNS provider definitions: (name, servers, doh_url) # Note: Google uses /resolve endpoint for JSON API, others use /dns-query DNS_PROVIDERS = [ @@ -284,8 +302,7 @@ def _current_dns_label() -> str: def get_dns_config() -> dict: - """ - Get the current DNS configuration. + """Get the current DNS configuration. Returns: Dict with keys: @@ -294,6 +311,7 @@ def get_dns_config() -> dict: - doh_url: str - DoH server URL (empty if disabled) - doh_enabled: bool - Whether DoH is active - is_auto_mode: bool - Whether auto-rotation is enabled + """ _ensure_initialized() @@ -317,21 +335,24 @@ def get_dns_config() -> dict: "is_auto_mode": _is_auto_dns_mode(), } + # Common helper functions for DNS resolution -def _decode_host(host: Union[str, bytes, None]) -> str: +def _decode_host(host: str | bytes | None) -> str: """Convert host to string, handling bytes and None cases.""" if host is None: return "" if isinstance(host, bytes): - return host.decode('utf-8') + return host.decode("utf-8") return str(host) -def _decode_port(port: Union[str, bytes, int, None]) -> int: + +def _decode_port(port: str | bytes | int | None) -> int: """Convert port to integer, handling various input types.""" if port is None: return 0 return int(port) + def _is_local_address(host_str: str) -> bool: """Check if an address is local/private and should bypass custom DNS. @@ -347,126 +368,135 @@ def _is_local_address(host_str: str) -> bool: host_lower = host_str.lower() # Check for localhost - if host_lower == 'localhost': + if host_lower == "localhost": return True # Check for simple hostnames (no dot = likely internal Docker/container name) - if '.' not in host_str: + if "." not in host_str: return True # Check for common internal TLDs - internal_tlds = ('.local', '.internal', '.lan', '.home', '.docker', '.localdomain') + internal_tlds = (".local", ".internal", ".lan", ".home", ".docker", ".localdomain") if any(host_lower.endswith(tld) for tld in internal_tlds): return True # Check for private/loopback/link-local IP addresses try: addr = ipaddress.ip_address(host_str) - return addr.is_private or addr.is_loopback or addr.is_link_local except ValueError: return False + else: + return addr.is_private or addr.is_loopback or addr.is_link_local + def _is_ip_address(host_str: str) -> bool: """Check if a string is a valid IP address (IPv4 or IPv6).""" try: ipaddress.ip_address(host_str) - return True except ValueError: return False + else: + return True -def _aa_hostnames() -> List[str]: + +def _aa_hostnames() -> list[str]: """Return hostname portions for all configured AA URLs.""" return [ - parsed.hostname for parsed in (urllib.parse.urlparse(url) for url in _aa_urls) + parsed.hostname + for parsed in (urllib.parse.urlparse(url) for url in _aa_urls) if parsed.hostname ] + def _is_aa_hostname(host_str: str) -> bool: """Check if a hostname matches any configured AA mirror host.""" return any(host_str.endswith(hostname) for hostname in _aa_hostnames()) + # Store the original getaddrinfo function original_getaddrinfo = socket.getaddrinfo + class DoHResolver: """DNS over HTTPS resolver implementation with caching.""" - + # Cache TTL in seconds (5 minutes) CACHE_TTL = 300 - - def __init__(self, provider_url: str, hostname: str, ip: str): + + def __init__(self, provider_url: str, hostname: str, ip: str) -> None: """Initialize DoH resolver with specified provider.""" self.base_url = provider_url.lower().strip() self.hostname = hostname # Store the hostname for hostname-based skipping - self.ip = ip # Store IP for direct connections + self.ip = ip # Store IP for direct connections self.session = requests.Session() # DNS cache: {(hostname, record_type): (ip_list, timestamp)} - self._cache: dict[tuple[str, str], tuple[List[str], datetime]] = {} - + self._cache: dict[tuple[str, str], tuple[list[str], datetime]] = {} + # Different headers based on provider - if 'google' in self.base_url: - self.session.headers.update({ - 'Accept': 'application/json', - }) + if "google" in self.base_url: + self.session.headers.update( + { + "Accept": "application/json", + } + ) else: - self.session.headers.update({ - 'Accept': 'application/dns-json', - }) - - def _get_cached(self, hostname: str, record_type: str) -> Optional[List[str]]: + self.session.headers.update( + { + "Accept": "application/dns-json", + } + ) + + def _get_cached(self, hostname: str, record_type: str) -> list[str] | None: """Get cached DNS result if still valid.""" key = (hostname, record_type) if key in self._cache: ips, timestamp = self._cache[key] if datetime.now() - timestamp < timedelta(seconds=self.CACHE_TTL): - logger.debug(f"DoH cache hit for {hostname}: {ips}") + logger.debug("DoH cache hit for %s: %s", hostname, ips) return ips - else: - # Cache expired, remove it - del self._cache[key] + # Cache expired, remove it + del self._cache[key] return None - - def _set_cached(self, hostname: str, record_type: str, ips: List[str]) -> None: + + def _set_cached(self, hostname: str, record_type: str, ips: list[str]) -> None: """Cache DNS result.""" if ips: # Only cache non-empty results self._cache[(hostname, record_type)] = (ips, datetime.now()) - - def resolve(self, hostname: str, record_type: str) -> List[str]: + + def resolve(self, hostname: str, record_type: str) -> list[str]: """Resolve a hostname using DoH. - + Args: hostname: The hostname to resolve record_type: The DNS record type (A or AAAA) - + Returns: List of resolved IP addresses + """ # Check if hostname is already an IP address, no need to resolve if _is_ip_address(hostname): - logger.debug(f"Skipping DoH resolution for IP address: {hostname}") + logger.debug("Skipping DoH resolution for IP address: %s", hostname) return [hostname] - + # Check if hostname is a private IP address, and skip DoH if it is if _is_local_address(hostname): - logger.debug(f"Skipping DoH resolution for private IP: {hostname}") + logger.debug("Skipping DoH resolution for private IP: %s", hostname) return [hostname] - + # Skip resolution for the DoH server itself to prevent recursion if hostname == self.hostname: - logger.debug(f"Skipping DoH resolution for DoH server itself: {hostname}") + logger.debug("Skipping DoH resolution for DoH server itself: %s", hostname) return [self.ip] - + # Check cache first cached = self._get_cached(hostname, record_type) if cached is not None: return cached - + try: - params = { - 'name': hostname, - 'type': 'AAAA' if record_type == 'AAAA' else 'A' - } - + params = {"name": hostname, "type": "AAAA" if record_type == "AAAA" else "A"} + response = self.session.get( self.base_url, params=params, @@ -475,33 +505,42 @@ class DoHResolver: verify=get_ssl_verify(self.base_url), ) response.raise_for_status() - + data = response.json() - if 'Answer' not in data: - logger.warning(f"DoH resolution failed for {hostname}: {data}") + if "Answer" not in data: + logger.warning("DoH resolution failed for %s: %s", hostname, data) return [] - - # Extract IP addresses from the response - answers = [answer['data'] for answer in data['Answer'] - if answer.get('type') == (28 if record_type == 'AAAA' else 1)] - + + # Extract IP addresses from the response + answers = [ + answer["data"] + for answer in data["Answer"] + if answer.get("type") == (28 if record_type == "AAAA" else 1) + ] + # Cache the result self._set_cached(hostname, record_type, answers) - - # Don't log here - the caller (custom_getaddrinfo) will log the final result - return answers - - except Exception as e: - logger.warning(f"DoH resolution failed for {hostname}: {e}") - return [] -def create_custom_resolver(servers: Optional[List[str]] = None): + # Don't log here - the caller (custom_getaddrinfo) will log the final result + except Exception as e: + logger.warning("DoH resolution failed for %s: %s", hostname, e) + return [] + else: + return answers + + +def create_custom_resolver( + servers: list[str] | None = None, +) -> dns.resolver.Resolver: """Create a custom DNS resolver using the specified or configured DNS servers.""" custom_resolver = dns.resolver.Resolver() custom_resolver.nameservers = servers if servers is not None else CUSTOM_DNS return custom_resolver -def resolve_with_custom_dns(resolver, hostname: str, record_type: str) -> List[str]: + +def resolve_with_custom_dns( + resolver: dns.resolver.Resolver, hostname: str, record_type: str +) -> list[str]: """Resolve hostname using custom DNS resolver.""" try: answers = resolver.resolve(hostname, record_type) @@ -511,43 +550,56 @@ def resolve_with_custom_dns(resolver, hostname: str, record_type: str) -> List[s # Don't trigger DNS switch here either - caller handles it return [] + def create_custom_getaddrinfo( - resolve_ipv4: Callable[[str], List[str]], - resolve_ipv6: Callable[[str], List[str]], - skip_check: Optional[Callable[[str], bool]] = None -): + resolve_ipv4: Callable[[str], list[str]], + resolve_ipv6: Callable[[str], list[str]], + skip_check: Callable[[str], bool] | None = None, +) -> Callable[ + [str | bytes | None, str | bytes | int | None, int, int, int, int], + Sequence[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]], +]: """Create a custom getaddrinfo function that uses the provided resolvers. - + Args: resolve_ipv4: Function to resolve IPv4 addresses resolve_ipv6: Function to resolve IPv6 addresses skip_check: Optional function to check if custom resolution should be skipped - + Returns: A custom getaddrinfo function + """ + def custom_getaddrinfo( - host: Union[str, bytes, None], - port: Union[str, bytes, int, None], + host: str | bytes | None, + port: str | bytes | int | None, family: int = 0, type: int = 0, proto: int = 0, - flags: int = 0 - ) -> Sequence[Tuple[AddressFamily, SocketKind, int, str, Tuple[Any, ...]]]: + flags: int = 0, + ) -> Sequence[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]]: host_str = _decode_host(host) port_int = _decode_port(port) - - def _log_results(source: str, provider_label: str, res: Sequence[Tuple[AddressFamily, SocketKind, int, str, Tuple[Any, ...]]], is_bypass: bool = False) -> None: + + def _log_results( + source: str, + provider_label: str, + res: Sequence[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]], + *, + is_bypass: bool = False, + ) -> None: """Emit a unified resolver log with the IPs returned. - + Args: source: Description of resolver source provider_label: Label for the DNS provider res: Resolution results is_bypass: If True, log at DEBUG level (for local/IP addresses) + """ # Skip logging entirely for localhost to reduce noise - if host_str in ('localhost', '127.0.0.1', '::1'): + if host_str in ("localhost", "127.0.0.1", "::1"): return try: ips = [entry[4][0] for entry in res if len(entry) >= 5 and entry[4]] @@ -558,163 +610,192 @@ def create_custom_getaddrinfo( logger.info(msg) except Exception: pass # Silently ignore logging failures - + # Skip custom resolution for IP addresses, local addresses, or if skip check passes - if _is_ip_address(host_str) or _is_local_address(host_str) or (skip_check and skip_check(host_str)): + if ( + _is_ip_address(host_str) + or _is_local_address(host_str) + or (skip_check and skip_check(host_str)) + ): # Quietly bypass custom resolution for IP/local targets res = original_getaddrinfo(host, port, family, type, proto, flags) _log_results("system resolver (bypass)", "system", res, is_bypass=True) return res - - results: list[Tuple[AddressFamily, SocketKind, int, str, Tuple[Any, ...]]] = [] - + + results: list[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]] = [] + try: # Try IPv4 (IPv6 disabled to avoid noisy AAAA failures) - if family == 0 or family == socket.AF_INET: + if family in {0, socket.AF_INET}: ipv4_answers = resolve_ipv4(host_str) - for answer in ipv4_answers: - results.append((socket.AF_INET, cast(SocketKind, type), proto, '', (answer, port_int))) - + results.extend( + [ + (socket.AF_INET, cast("SocketKind", type), proto, "", (answer, port_int)) + for answer in ipv4_answers + ] + ) + if results: _log_results("custom resolver", _current_dns_label(), results) return results - + except Exception as e: - logger.warning(f"Custom DNS resolution failed for {host_str}: {e}, falling back to system DNS") + logger.warning( + "Custom DNS resolution failed for %s: %s, falling back to system DNS", host_str, e + ) # Trigger DNS switch on failure (if auto mode) - if _is_auto_dns_mode() and not _is_local_address(host_str) and not _is_ip_address(host_str): - # Only switch if we haven't exhausted all providers - if _current_dns_index < len(DNS_PROVIDERS): - logger.info(f"Requesting DNS provider switch after custom resolver failure for {host_str}") - switch_dns_provider() - + if ( + _is_auto_dns_mode() + and not _is_local_address(host_str) + and not _is_ip_address(host_str) + and _current_dns_index < len(DNS_PROVIDERS) + ): + logger.info( + "Requesting DNS provider switch after custom resolver failure for %s", host_str + ) + switch_dns_provider() + # Fall back to system DNS if custom resolution fails - logger.info(f"Custom DNS returned no addresses for {host_str}; falling back to system resolver") + logger.info( + "Custom DNS returned no addresses for %s; falling back to system resolver", host_str + ) try: res = original_getaddrinfo(host, port, family, type, proto, flags) _log_results("system resolver (fallback)", "system", res) - return res - except Exception as e: - logger.error(f"System DNS resolution also failed for {host_str}: {e}") + except Exception: + logger.exception("System DNS resolution also failed for %s", host_str) # Last resort: Try to connect to the hostname directly - if family == 0 or family == socket.AF_INET: - logger.warning(f"Using direct hostname as last resort for {host_str}") - return [(socket.AF_INET, cast(SocketKind, type), proto, '', (host_str, port_int))] - else: - raise # Re-raise the exception if we can't provide a last resort - + if family in {0, socket.AF_INET}: + logger.warning("Using direct hostname as last resort for %s", host_str) + return [(socket.AF_INET, cast("SocketKind", type), proto, "", (host_str, port_int))] + raise # Re-raise the exception if we can't provide a last resort + else: + return res + return custom_getaddrinfo -def create_system_failover_getaddrinfo(): + +def create_system_failover_getaddrinfo() -> Callable[ + [str | bytes | None, str | bytes | int | None, int, int, int, int], + Sequence[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]], +]: """Wrap system getaddrinfo to trigger DNS provider switch on failure.""" _switch_logged: set[str] = set() - + def system_failover_getaddrinfo( - host: Union[str, bytes, None], - port: Union[str, bytes, int, None], + host: str | bytes | None, + port: str | bytes | int | None, family: int = 0, type: int = 0, proto: int = 0, - flags: int = 0 - ) -> Sequence[Tuple[AddressFamily, SocketKind, int, str, Tuple[Any, ...]]]: + flags: int = 0, + ) -> Sequence[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]]: host_str = _decode_host(host) try: return original_getaddrinfo(host, port, family, type, proto, flags) except Exception as e: if host_str not in _switch_logged: - logger.warning(f"System DNS resolution failed for {host_str}: {e}") - + logger.warning("System DNS resolution failed for %s: %s", host_str, e) + # Trigger DNS switch only in auto mode for non-local targets - if _is_auto_dns_mode() and not _is_ip_address(host_str) and not _is_local_address(host_str): - if _current_dns_index + 1 < len(DNS_PROVIDERS): - if host_str not in _switch_logged: - logger.info(f"Switching DNS provider after system DNS failure for {host_str}") - _switch_logged.add(host_str) - if switch_dns_provider(): - return socket.getaddrinfo(host, port, family, type, proto, flags) + if ( + _is_auto_dns_mode() + and not _is_ip_address(host_str) + and not _is_local_address(host_str) + and _current_dns_index + 1 < len(DNS_PROVIDERS) + ): + if host_str not in _switch_logged: + logger.info("Switching DNS provider after system DNS failure for %s", host_str) + _switch_logged.add(host_str) + if switch_dns_provider(): + return socket.getaddrinfo(host, port, family, type, proto, flags) raise - + return system_failover_getaddrinfo + def _init_doh_resolver_internal(doh_server: str) -> DoHResolver: """Internal: Initialize DNS over HTTPS resolver with specified server. - + Args: doh_server: The DoH server URL - + Returns: Configured DoHResolver instance + """ # Pre-resolve the DoH server hostname to prevent recursion url = urllib.parse.urlparse(doh_server) - server_hostname = url.hostname if url.hostname else '' - + server_hostname = url.hostname or "" + # Use system DNS for DoH server to prevent circular dependencies try: # Temporarily restore original getaddrinfo to resolve DoH server temp_getaddrinfo = socket.getaddrinfo socket.getaddrinfo = original_getaddrinfo - + server_ip = socket.gethostbyname(server_hostname) - logger.info(f"DoH server {server_hostname} resolved to IP: {server_ip}") - + logger.info("DoH server %s resolved to IP: %s", server_hostname, server_ip) + # Restore custom getaddrinfo if it was previously set socket.getaddrinfo = temp_getaddrinfo - except Exception as e: - logger.error(f"Failed to resolve DoH server {server_hostname}: {e}") + except Exception: + logger.exception("Failed to resolve DoH server %s", server_hostname) # Fall back to a known public DNS if resolution fails server_ip = "1.1.1.1" - logger.info(f"Using fallback IP for DoH server: {server_ip}") - + logger.info("Using fallback IP for DoH server: %s", server_ip) + # Create DoH resolver doh_resolver = DoHResolver(doh_server, server_hostname, server_ip) - + # Create resolver functions - def resolve_ipv4(hostname: str) -> List[str]: - return doh_resolver.resolve(hostname, 'A') - - def resolve_ipv6(hostname: str) -> List[str]: - return doh_resolver.resolve(hostname, 'AAAA') - + def resolve_ipv4(hostname: str) -> list[str]: + return doh_resolver.resolve(hostname, "A") + + def resolve_ipv6(hostname: str) -> list[str]: + return doh_resolver.resolve(hostname, "AAAA") + # Skip DoH resolution for the DoH server itself, IP addresses, and private addresses def skip_doh(hostname: str) -> bool: - return (hostname == server_hostname or - hostname == server_ip or - _is_ip_address(hostname) or - _is_local_address(hostname)) - + return ( + hostname in (server_hostname, server_ip) + or _is_ip_address(hostname) + or _is_local_address(hostname) + ) + # Replace socket.getaddrinfo with our DoH-enabled version - socket.getaddrinfo = cast(Any, create_custom_getaddrinfo( - resolve_ipv4, resolve_ipv6, skip_doh - )) - + socket.getaddrinfo = cast( + "Any", create_custom_getaddrinfo(resolve_ipv4, resolve_ipv6, skip_doh) + ) + logger.info("DoH resolver successfully configured and activated") return doh_resolver -def _init_custom_resolver_internal(servers: List[str]): +def _init_custom_resolver_internal(servers: list[str]) -> dns.resolver.Resolver: """Internal: Initialize custom DNS resolver with specified servers. - + Args: servers: List of DNS server IPs to use + """ custom_resolver = create_custom_resolver(servers) - + # Create resolver functions - def resolve_ipv4(hostname: str) -> List[str]: - return resolve_with_custom_dns(custom_resolver, hostname, 'A') - - def resolve_ipv6(hostname: str) -> List[str]: - return resolve_with_custom_dns(custom_resolver, hostname, 'AAAA') - + def resolve_ipv4(hostname: str) -> list[str]: + return resolve_with_custom_dns(custom_resolver, hostname, "A") + + def resolve_ipv6(hostname: str) -> list[str]: + return resolve_with_custom_dns(custom_resolver, hostname, "AAAA") + # Replace socket.getaddrinfo with our custom resolver - socket.getaddrinfo = cast(Any, create_custom_getaddrinfo(resolve_ipv4, resolve_ipv6)) - + socket.getaddrinfo = cast("Any", create_custom_getaddrinfo(resolve_ipv4, resolve_ipv6)) + logger.info("Custom DNS resolver successfully configured and activated") return custom_resolver -def init_doh_resolver(doh_server: str = ""): +def init_doh_resolver(doh_server: str = "") -> DoHResolver | None: """Initialize DNS over HTTPS resolver.""" server = doh_server or DOH_SERVER if not server: @@ -722,12 +803,13 @@ def init_doh_resolver(doh_server: str = ""): return _init_doh_resolver_internal(server) -def init_custom_resolver(): +def init_custom_resolver() -> dns.resolver.Resolver | None: """Initialize custom DNS resolver using configured DNS servers.""" if not CUSTOM_DNS: return None return _init_custom_resolver_internal(CUSTOM_DNS) + def switch_dns_provider() -> bool: """Switch to next DNS provider (auto mode only).""" global CUSTOM_DNS, DOH_SERVER, _current_dns_index, _dns_exhausted_logged @@ -749,7 +831,7 @@ def switch_dns_provider() -> bool: app_config.CUSTOM_DNS = servers app_config.DOH_SERVER = doh - logger.warning(f"Switched DNS provider to: {name} (using DoH)") + logger.warning("Switched DNS provider to: %s (using DoH)", name) _save_state(dns_provider=name) init_dns_resolvers() @@ -772,9 +854,9 @@ def rotate_dns_provider() -> bool: return switch_dns_provider() + def rotate_dns_and_reset_aa() -> bool: - """ - Switch DNS provider (auto mode) and reset AA URL list to the first entry. + """Switch DNS provider (auto mode) and reset AA URL list to the first entry. Returns True if DNS switched; False if no providers left or not in auto mode. Note: This function can be called during initialization, so we must NOT call @@ -796,23 +878,22 @@ def rotate_dns_and_reset_aa() -> bool: # Auto mode always resets to the first mirror to restart the cascade _current_aa_url_index = 0 _aa_base_url = _aa_urls[0] if _aa_urls else "https://annas-archive.gl" - logger.info(f"After DNS switch, resetting AA URL to: {_aa_base_url}") + logger.info("After DNS switch, resetting AA URL to: %s", _aa_base_url) _save_state(aa_url=_aa_base_url) else: # Keep the user's configured primary mirror (if it exists in the list), # otherwise keep the configured URL as-is (custom/env). - if configured_url in _aa_urls: - _current_aa_url_index = _aa_urls.index(configured_url) - else: - _current_aa_url_index = 0 + _current_aa_url_index = _aa_urls.index(configured_url) if configured_url in _aa_urls else 0 _aa_base_url = configured_url - logger.info(f"After DNS switch, keeping configured AA URL: {_aa_base_url}") + logger.info("After DNS switch, keeping configured AA URL: %s", _aa_base_url) _save_state(aa_url=_aa_base_url) return True -def set_dns_provider(provider: str, manual_servers: list[str] | None = None, use_doh: bool | None = None) -> bool: - """ - Set DNS to a specific provider or manual servers. + +def set_dns_provider( + provider: str, manual_servers: list[str] | None = None, *, use_doh: bool | None = None +) -> bool: + """Set DNS to a specific provider or manual servers. Args: provider: One of 'auto', 'system', 'google', 'cloudflare', 'quad9', 'opendns', 'manual' @@ -822,6 +903,7 @@ def set_dns_provider(provider: str, manual_servers: list[str] | None = None, use Returns: True if DNS was changed successfully. + """ global CUSTOM_DNS, DOH_SERVER, _current_dns_index, _dns_exhausted_logged @@ -868,7 +950,7 @@ def set_dns_provider(provider: str, manual_servers: list[str] | None = None, use DOH_SERVER = "" # No DoH for manual servers app_config.CUSTOM_DNS = manual_servers app_config.DOH_SERVER = "" - logger.info(f"DNS set to manual servers: {manual_servers}") + logger.info("DNS set to manual servers: %s", manual_servers) init_dns_resolvers() _notify_dns_rotation("manual", manual_servers, "") return True @@ -884,20 +966,20 @@ def set_dns_provider(provider: str, manual_servers: list[str] | None = None, use app_config.CUSTOM_DNS = servers app_config.DOH_SERVER = DOH_SERVER doh_status = "DoH enabled" if doh_enabled else "standard DNS" - logger.info(f"DNS set to: {name} ({doh_status})") + logger.info("DNS set to: %s (%s)", name, doh_status) _save_state(dns_provider=name) init_dns_resolvers() _notify_dns_rotation(name, servers, DOH_SERVER) return True - logger.warning(f"Unknown DNS provider: {provider}") + logger.warning("Unknown DNS provider: %s", provider) return False -def init_dns_resolvers(): +def init_dns_resolvers() -> None: """Initialize DNS resolvers based on configuration.""" global CUSTOM_DNS, DOH_SERVER - + if _is_auto_dns_mode(): if _current_dns_index >= 0: name, servers, doh = DNS_PROVIDERS[_current_dns_index] @@ -905,31 +987,31 @@ def init_dns_resolvers(): DOH_SERVER = doh app_config.CUSTOM_DNS = servers app_config.DOH_SERVER = doh - logger.info(f"Using DNS provider: {name} (DoH enabled)") + logger.info("Using DNS provider: %s (DoH enabled)", name) else: CUSTOM_DNS = [] DOH_SERVER = "" app_config.CUSTOM_DNS = [] app_config.DOH_SERVER = "" logger.debug("Using system DNS (auto mode - will switch on failure)") - socket.getaddrinfo = cast(Any, create_system_failover_getaddrinfo()) + socket.getaddrinfo = cast("Any", create_system_failover_getaddrinfo()) return - + if CUSTOM_DNS: init_custom_resolver() if DOH_SERVER: init_doh_resolver(DOH_SERVER) -def _get_initial_dns_config() -> tuple[str, List[str] | None, bool]: - """ - Determine initial DNS configuration from config singleton. +def _get_initial_dns_config() -> tuple[str, list[str] | None, bool]: + """Determine initial DNS configuration from config singleton. The config singleton already handles ENV > config file > default priority, so we just read from config. Returns: Tuple of (provider, manual_servers, use_doh) + """ provider = str(app_config.get("CUSTOM_DNS", "auto")).lower().strip() use_doh = app_config.get("USE_DOH", True) @@ -942,14 +1024,23 @@ def _get_initial_dns_config() -> tuple[str, List[str] | None, bool]: manual_servers = [s.strip() for s in manual_dns.split(",") if s.strip()] # Handle legacy format: IPs directly in CUSTOM_DNS setting - if provider and provider not in ("auto", "system", "google", "cloudflare", "quad9", "opendns", "manual", ""): + if provider and provider not in ( + "auto", + "system", + "google", + "cloudflare", + "quad9", + "opendns", + "manual", + "", + ): # Check if it looks like IP addresses parts = provider.split(",") potential_ips = [p.strip() for p in parts if p.strip()] if potential_ips and all(_looks_like_ip(p) for p in potential_ips): manual_servers = potential_ips provider = "manual" - logger.info(f"Detected legacy DNS format, treating as manual: {manual_servers}") + logger.info("Detected legacy DNS format, treating as manual: %s", manual_servers) return provider or "auto", manual_servers, use_doh @@ -959,9 +1050,11 @@ def _looks_like_ip(s: str) -> bool: # Simple heuristic: contains only digits, dots, and colons return s.replace(".", "").replace(":", "").isdigit() -def _build_aa_urls() -> List[str]: + +def _build_aa_urls() -> list[str]: """Build list of available AA URLs from centralized mirror config.""" from shelfmark.core.mirrors import get_aa_mirrors + return get_aa_mirrors() @@ -984,17 +1077,19 @@ def _initialize_aa_state() -> None: # If AA_BASE_URL is pinned to a custom URL that's not in the mirror list, we still # want to treat it as the active base (and rewrite known mirror links to it). if configured_url != "auto" and configured_url not in _aa_urls: - _aa_urls = [configured_url] + _aa_urls + _aa_urls = [configured_url, *_aa_urls] if configured_url == "auto": - if state.get('aa_base_url') and state['aa_base_url'] in _aa_urls: - _current_aa_url_index = _aa_urls.index(state['aa_base_url']) - _aa_base_url = state['aa_base_url'] + if state.get("aa_base_url") and state["aa_base_url"] in _aa_urls: + _current_aa_url_index = _aa_urls.index(state["aa_base_url"]) + _aa_base_url = state["aa_base_url"] else: - logger.debug(f"AA_BASE_URL: auto, checking available urls {_aa_urls}") + logger.debug("AA_BASE_URL: auto, checking available urls %s", _aa_urls) for i, url in enumerate(_aa_urls): try: - response = requests.get(url, proxies=get_proxies(url), timeout=3, verify=get_ssl_verify(url)) + response = requests.get( + url, proxies=get_proxies(url), timeout=3, verify=get_ssl_verify(url) + ) if response.status_code == 200: _current_aa_url_index = i _aa_base_url = url @@ -1006,15 +1101,16 @@ def _initialize_aa_state() -> None: _aa_base_url = _aa_urls[0] _current_aa_url_index = 0 elif configured_url not in _aa_urls: - logger.info(f"AA_BASE_URL set to custom value {configured_url}; skipping auto-switch") + logger.info("AA_BASE_URL set to custom value %s; skipping auto-switch", configured_url) _aa_base_url = configured_url else: _current_aa_url_index = _aa_urls.index(configured_url) _aa_base_url = configured_url - logger.info(f"AA_BASE_URL: {_aa_base_url}") + logger.info("AA_BASE_URL: %s", _aa_base_url) -def init_dns(force: bool = False) -> None: + +def init_dns(*, force: bool = False) -> None: """Initialize DNS state and resolvers using set_dns_provider() for consistency.""" global state, _dns_initialized, _current_dns_index if _dns_initialized and not force: @@ -1023,36 +1119,35 @@ def init_dns(force: bool = False) -> None: # Double-check after acquiring lock if _dns_initialized and not force: return - # Do work first, set flag after to prevent race conditions - try: - logger.debug(f"Initializing DNS (using {'gevent' if _using_gevent_locks else 'threading'} locks)") - state = _load_state() + # Do work first, set flag after to prevent race conditions. + logger.debug( + "Initializing DNS (using %s locks)", "gevent" if _using_gevent_locks else "threading" + ) + state = _load_state() - # Get initial DNS configuration from environment - provider, manual_servers, use_doh = _get_initial_dns_config() + # Get initial DNS configuration from environment + provider, manual_servers, use_doh = _get_initial_dns_config() - if provider == "auto": - # Auto mode: check for persisted provider from previous rotation - persisted = state.get('dns_provider') if state else None - if persisted: - for i, (name, _, _) in enumerate(DNS_PROVIDERS): - if name == persisted: - _current_dns_index = i - logger.info(f"Restored DNS provider from state: {name}") - break - # Use init_dns_resolvers() for auto mode to preserve rotation capability - init_dns_resolvers() - else: - # Non-auto mode: use set_dns_provider() for consistent initialization - set_dns_provider(provider, manual_servers, use_doh=use_doh) + if provider == "auto": + # Auto mode: check for persisted provider from previous rotation + persisted = state.get("dns_provider") if state else None + if persisted: + for i, (name, _, _) in enumerate(DNS_PROVIDERS): + if name == persisted: + _current_dns_index = i + logger.info("Restored DNS provider from state: %s", name) + break + # Use init_dns_resolvers() for auto mode to preserve rotation capability + init_dns_resolvers() + else: + # Non-auto mode: use set_dns_provider() for consistent initialization + set_dns_provider(provider, manual_servers, use_doh=use_doh) - # Only set flag AFTER work completes successfully - _dns_initialized = True - except Exception: - # Flag stays False so retry is possible - raise + # Only set flag AFTER work completes successfully. + _dns_initialized = True -def init_aa(force: bool = False) -> None: + +def init_aa(*, force: bool = False) -> None: """Initialize AA mirror selection.""" global state, _aa_initialized if _aa_initialized and not force: @@ -1061,19 +1156,15 @@ def init_aa(force: bool = False) -> None: # Double-check after acquiring lock if _aa_initialized and not force: return - # Do work first, set flag after to prevent race conditions - try: - state = _load_state() - _initialize_aa_state() - # Only set flag AFTER work completes successfully - _aa_initialized = True - except Exception: - # Flag stays False so retry is possible - raise + # Do work first, set flag after to prevent race conditions. + state = _load_state() + _initialize_aa_state() + # Only set flag AFTER work completes successfully. + _aa_initialized = True -def init(force: bool = False) -> None: - """ - Initialize network state (DNS resolvers and AA mirror selection). + +def init(*, force: bool = False) -> None: + """Initialize network state (DNS resolvers and AA mirror selection). Called lazily on first network operation. Safe to call repeatedly; later calls no-op unless force=True. @@ -1086,22 +1177,20 @@ def init(force: bool = False) -> None: if _initialized and not force: return # Do the work first, then set flag to prevent race conditions - # where another thread sees _initialized=True but _aa_base_url is still empty - try: - init_dns(force=force) - init_aa(force=force) - _apply_ssl_warning_suppression() - # Only set flag AFTER work completes successfully - _initialized = True - except Exception: - # Flag stays False so retry is possible - raise + # where another thread sees _initialized=True but _aa_base_url is still empty. + init_dns(force=force) + init_aa(force=force) + _apply_ssl_warning_suppression() + # Only set flag AFTER work completes successfully. + _initialized = True -def get_aa_base_url(): + +def get_aa_base_url() -> str: """Get current AA base URL.""" _ensure_initialized() return _aa_base_url + def is_aa_auto_mode() -> bool: """Return True when AA_BASE_URL is set to 'auto' (mirror failover enabled).""" configured_url = normalize_http_url( @@ -1113,11 +1202,13 @@ def is_aa_auto_mode() -> bool: configured_url = "auto" return configured_url == "auto" -def get_available_aa_urls(): + +def get_available_aa_urls() -> list[str]: """Get list of configured AA URLs (copy).""" _ensure_initialized() return _aa_urls.copy() + def set_aa_url_index(new_index: int) -> bool: """Set AA base URL by index in available list; returns True if applied.""" _ensure_initialized() @@ -1126,19 +1217,20 @@ def set_aa_url_index(new_index: int) -> bool: return False _current_aa_url_index = new_index _aa_base_url = _aa_urls[_current_aa_url_index] - logger.info(f"Set AA URL to: {_aa_base_url}") + logger.info("Set AA URL to: %s", _aa_base_url) _save_state(aa_url=_aa_base_url) return True + class AAMirrorSelector: - """ - Small helper to keep AA mirror switching consistent across call sites. + """Small helper to keep AA mirror switching consistent across call sites. Tracks attempts per DNS cycle and rewrites URLs safely. """ + def __init__(self) -> None: self._ensure_fresh_state(reset_attempts=True) - def _ensure_fresh_state(self, reset_attempts: bool = False) -> None: + def _ensure_fresh_state(self, *, reset_attempts: bool = False) -> None: _ensure_initialized() self.aa_urls = get_available_aa_urls() self._index = self._safe_index(get_aa_base_url()) @@ -1158,9 +1250,8 @@ class AAMirrorSelector: return url.replace(base, self.current_base, 1) return url - def next_mirror_or_rotate_dns(self, allow_dns: bool = True) -> tuple[Optional[str], str]: - """ - Advance to next mirror; if exhausted and allowed, rotate DNS and reset to first. + def next_mirror_or_rotate_dns(self, *, allow_dns: bool = True) -> tuple[str | None, str]: + """Advance to next mirror; if exhausted and allowed, rotate DNS and reset to first. Returns (new_base, action) where action is 'mirror', 'dns', or 'exhausted'. """ self.attempts_this_dns += 1 @@ -1180,11 +1271,15 @@ class AAMirrorSelector: self._ensure_fresh_state(reset_attempts=False) return self.current_base, "mirror" + # Configure urllib opener with appropriate headers opener = urllib.request.build_opener() opener.addheaders = [ - ('User-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' - 'AppleWebKit/537.36 (KHTML, like Gecko) ' - 'Chrome/129.0.0.0 Safari/537.3') + ( + "User-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/129.0.0.0 Safari/537.3", + ) ] urllib.request.install_opener(opener) diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 888befb2..1e2d0168 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -12,15 +12,18 @@ from concurrent.futures import Future, ThreadPoolExecutor from email.utils import parseaddr from pathlib import Path from threading import Event, Lock -from typing import Any, Dict, List, Optional, Tuple +from typing import Any from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.models import DownloadTask, QueueStatus, SearchMode from shelfmark.core.queue import book_queue -from shelfmark.core.request_helpers import normalize_optional_text, normalize_positive_int -from shelfmark.core.utils import transform_cover_url, is_audiobook as check_audiobook -from shelfmark.config import env as env_config +from shelfmark.core.request_helpers import ( + normalize_optional_text, + normalize_positive_int, +) +from shelfmark.core.utils import is_audiobook as check_audiobook +from shelfmark.core.utils import transform_cover_url from shelfmark.download.fs import run_blocking_io from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path from shelfmark.download.postprocess.router import post_process_download @@ -46,21 +49,25 @@ WEBSOCKET_AVAILABLE = True try: from shelfmark.api.websocket import ws_manager except ImportError: - logger.error("WebSocket unavailable - real-time updates disabled") + logger.exception("WebSocket unavailable - real-time updates disabled") ws_manager = None WEBSOCKET_AVAILABLE = False # Progress update throttling - track last broadcast time per book -_progress_last_broadcast: Dict[str, float] = {} +_progress_last_broadcast: dict[str, float] = {} _progress_lock = Lock() # Stall detection - track last activity time per download -_last_activity: Dict[str, float] = {} -_last_progress_value: Dict[str, float] = {} +_last_activity: dict[str, float] = {} +_last_progress_value: dict[str, float] = {} # De-duplicate status updates (keep-alive updates shouldn't spam clients) -_last_status_event: Dict[str, Tuple[str, Optional[str]]] = {} +_last_status_event: dict[str, tuple[str, str | None]] = {} STALL_TIMEOUT = 300 # 5 minutes without progress/status update = stalled COORDINATOR_LOOP_ERROR_RETRY_DELAY = 1.0 +_PROGRESS_BROADCAST_START_PERCENT = 1 +_PROGRESS_BROADCAST_COMPLETE_PERCENT = 99 +_PROGRESS_BROADCAST_MIN_DELTA = 10 + def _is_plain_email_address(value: str) -> bool: parsed = parseaddr(value or "")[1] @@ -68,12 +75,13 @@ def _is_plain_email_address(value: str) -> bool: def _resolve_email_destination( - user_id: Optional[int] = None, -) -> Tuple[Optional[str], Optional[str]]: + user_id: int | None = None, +) -> tuple[str | None, str | None]: """Resolve the destination email address for email output mode. Returns: (email_to, error_message) + """ configured_recipient = str(config.get("EMAIL_RECIPIENT", "", user_id=user_id) or "").strip() if configured_recipient: @@ -84,7 +92,7 @@ def _resolve_email_destination( return None, None -def _parse_release_search_mode(value: Any) -> SearchMode: +def _parse_release_search_mode(value: object) -> SearchMode: if isinstance(value, SearchMode): return value if value is None: @@ -93,29 +101,32 @@ def _parse_release_search_mode(value: Any) -> SearchMode: try: return SearchMode(value.strip().lower()) except ValueError as exc: - raise ValueError(f"Invalid search_mode: {value}") from exc - raise ValueError(f"Invalid search_mode: {value}") + msg = f"Invalid search_mode: {value}" + raise ValueError(msg) from exc + msg = f"Invalid search_mode: {value}" + raise ValueError(msg) -def _optional_number(value: Any) -> Optional[float]: + +def _optional_number(value: object) -> float | None: if isinstance(value, (int, float)) and not isinstance(value, bool): return float(value) try: return float(value) - except (TypeError, ValueError): + except TypeError, ValueError: return None -def _optional_positive_int(value: Any) -> Optional[int]: +def _optional_positive_int(value: object) -> int | None: if isinstance(value, bool): return None try: parsed = int(value) - except (TypeError, ValueError): + except TypeError, ValueError: return None return parsed if parsed > 0 else None -def _seed_time_seconds_to_minutes(value: Any) -> Optional[int]: +def _seed_time_seconds_to_minutes(value: object) -> int | None: seed_time_seconds = _optional_positive_int(value) if seed_time_seconds is None: return None @@ -124,7 +135,7 @@ def _seed_time_seconds_to_minutes(value: Any) -> Optional[int]: def _build_retry_resolution_fields( release_data: dict[str, Any], -) -> Dict[str, Any]: +) -> dict[str, Any]: """Persist generic resolved-download data needed for restart-safe retries.""" extra = release_data.get("extra") if not isinstance(extra, dict): @@ -139,9 +150,7 @@ def _build_retry_resolution_fields( release_data.get("seeding_time_limit_minutes") ) if seeding_time_limit_minutes is None: - seeding_time_limit_minutes = _seed_time_seconds_to_minutes( - extra.get("minimum_seed_time") - ) + seeding_time_limit_minutes = _seed_time_seconds_to_minutes(extra.get("minimum_seed_time")) return { "retry_download_url": normalize_optional_text(release_data.get("download_url")), @@ -159,47 +168,49 @@ def _build_retry_resolution_fields( def queue_release( release_data: dict, priority: int = 0, - user_id: Optional[int] = None, - username: Optional[str] = None, -) -> Tuple[bool, Optional[str]]: + user_id: int | None = None, + username: str | None = None, +) -> tuple[bool, str | None]: """Add a release to the download queue. Returns (success, error_message).""" try: - source = release_data['source'] - extra = release_data.get('extra', {}) - raw_request_id = release_data.get('_request_id') - request_id: Optional[int] = None + source = release_data["source"] + extra = release_data.get("extra", {}) + raw_request_id = release_data.get("_request_id") + request_id: int | None = None if isinstance(raw_request_id, int) and raw_request_id > 0: request_id = raw_request_id search_mode = _parse_release_search_mode(release_data.get("search_mode")) # Get author, year, preview, and content_type from top-level (preferred) or extra (fallback) - author = release_data.get('author') or extra.get('author') - year = release_data.get('year') or extra.get('year') - preview = release_data.get('preview') or extra.get('preview') - content_type = release_data.get('content_type') or extra.get('content_type') + author = release_data.get("author") or extra.get("author") + year = release_data.get("year") or extra.get("year") + preview = release_data.get("preview") or extra.get("preview") + content_type = release_data.get("content_type") or extra.get("content_type") source_url_raw = ( - release_data.get('download_url') - or release_data.get('source_url') - or release_data.get('info_url') - or extra.get('detail_url') - or extra.get('source_url') + release_data.get("download_url") + or release_data.get("source_url") + or release_data.get("info_url") + or extra.get("detail_url") + or extra.get("source_url") ) source_url = source_url_raw.strip() if isinstance(source_url_raw, str) else None if source_url == "": source_url = None # Get series info for library naming templates - series_name = release_data.get('series_name') or extra.get('series_name') - series_position = release_data.get('series_position') or extra.get('series_position') - subtitle = release_data.get('subtitle') or extra.get('subtitle') + series_name = release_data.get("series_name") or extra.get("series_name") + series_position = release_data.get("series_position") or extra.get("series_position") + subtitle = release_data.get("subtitle") or extra.get("subtitle") - books_output_mode = str( - config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder" - ).strip().lower() + books_output_mode = ( + str(config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder") + .strip() + .lower() + ) is_audiobook = check_audiobook(content_type) output_mode = "folder" if is_audiobook else books_output_mode - output_args: Dict[str, Any] = {} + output_args: dict[str, Any] = {} retry_resolution_fields = _build_retry_resolution_fields(release_data) if output_mode == "email" and not is_audiobook: @@ -211,13 +222,13 @@ def queue_release( # Create a source-agnostic download task from release data task = DownloadTask( - task_id=release_data['source_id'], + task_id=release_data["source_id"], source=source, - title=release_data.get('title', 'Unknown'), + title=release_data.get("title", "Unknown"), author=author, year=year, - format=release_data.get('format'), - size=release_data.get('size'), + format=release_data.get("format"), + size=release_data.get("size"), preview=preview, content_type=content_type, source_url=source_url, @@ -235,17 +246,15 @@ def queue_release( ) if not book_queue.add(task): - logger.info(f"Release already in queue: {task.title}") + logger.info("Release already in queue: %s", task.title) return False, "Release is already in the download queue" - logger.info(f"Release queued with priority {priority}: {task.title}") + logger.info("Release queued with priority %s: %s", priority, task.title) # Broadcast status update via WebSocket if ws_manager: ws_manager.broadcast_status_update(queue_status()) - return True, None - except ValueError as e: error_msg = str(e) logger.warning(error_msg) @@ -258,12 +267,15 @@ def queue_release( error_msg = f"Error queueing release: {e}" logger.error_trace(error_msg) return False, error_msg + else: + return True, None -def queue_status(user_id: Optional[int] = None) -> Dict[str, Dict[str, Any]]: + +def queue_status(user_id: int | None = None) -> dict[str, dict[str, Any]]: """Get current status of the download queue.""" status = book_queue.get_status(user_id=user_id) - for _, tasks in status.items(): - for _, task in tasks.items(): + for tasks in status.values(): + for task in tasks.values(): if task.download_path and not run_blocking_io(os.path.exists, task.download_path): task.download_path = None @@ -276,7 +288,8 @@ def queue_status(user_id: Optional[int] = None) -> Dict[str, Dict[str, Any]]: for status_type, tasks in status.items() } -def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask]]: + +def get_book_data(task_id: str) -> tuple[bytes | None, DownloadTask | None]: """Get downloaded file data for a specific task.""" task = None try: @@ -288,7 +301,7 @@ def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask] if not path: return None, task - with open(path, "rb") as f: + with Path(path).open("rb") as f: return f.read(), task except Exception as e: logger.error_trace(f"Error getting book data: {e}") @@ -296,6 +309,7 @@ def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask] task.download_path = None return None, task + def _has_staged_retry_source(task: DownloadTask) -> bool: """Whether a failed task still has a staged file available for retry.""" staged_path = task.staged_path.strip() if isinstance(task.staged_path, str) else "" @@ -313,8 +327,8 @@ def _has_fresh_retry_context(task: DownloadTask) -> bool: def can_retry_download_task( - task: Optional[DownloadTask], - status: Optional[QueueStatus], + task: DownloadTask | None, + status: QueueStatus | None, ) -> bool: """Whether the task can be manually retried from the Activity UI.""" if task is None or status not in (QueueStatus.ERROR, QueueStatus.CANCELLED): @@ -329,10 +343,10 @@ def can_retry_download_task( return _has_staged_retry_source(task) -def serialize_task_for_retry(task: DownloadTask) -> Dict[str, Any]: +def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]: """Serialize the task state needed for restart-safe retries.""" raw_search_mode = getattr(task, "search_mode", None) - search_mode: Optional[str] = None + search_mode: str | None = None if isinstance(raw_search_mode, SearchMode): search_mode = raw_search_mode.value elif isinstance(raw_search_mode, str): @@ -374,7 +388,7 @@ def serialize_task_for_retry(task: DownloadTask) -> Dict[str, Any]: } -def _restore_task_from_retry_payload(payload: Any) -> Optional[DownloadTask]: +def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None: if not isinstance(payload, dict): return None @@ -423,18 +437,16 @@ def _restore_task_from_retry_payload(payload: Any) -> Optional[DownloadTask]: retry_seeding_time_limit_minutes=_optional_positive_int( payload.get("retry_seeding_time_limit_minutes") ), - can_retry_without_staged_source=bool( - payload.get("can_retry_without_staged_source", True) - ), + can_retry_without_staged_source=bool(payload.get("can_retry_without_staged_source", True)), ) def retry_persisted_download( - payload: Any, + payload: object, *, - final_status: Any, + final_status: object, priority: int = -10, -) -> Tuple[bool, Optional[str]]: +) -> tuple[bool, str | None]: """Retry a persisted download row after the in-memory task has been lost.""" task = _restore_task_from_retry_payload(payload) if task is None: @@ -453,13 +465,8 @@ def retry_persisted_download( if normalized_status in {"active", "cancelled"} and not has_fresh_retry_context: return False, "Download cannot be retried" - if ( - task.request_id is not None - and normalized_status == "error" - and not has_staged_retry_source - ): - if task.request_id is not None: - return False, "Request-linked downloads must be retried from requests" + if task.request_id is not None and normalized_status == "error" and not has_staged_retry_source: + return False, "Request-linked downloads must be retried from requests" if ( task.request_id is None @@ -486,33 +493,33 @@ def retry_persisted_download( def _task_to_dict( task: DownloadTask, - current_status: Optional[QueueStatus] = None, -) -> Dict[str, Any]: + current_status: QueueStatus | None = None, +) -> dict[str, Any]: """Convert DownloadTask to dict for frontend, transforming cover URLs.""" # Transform external preview URLs to local proxy URLs preview = transform_cover_url(task.preview, task.task_id) retry_status = current_status or book_queue.get_task_status(task.task_id) return { - 'id': task.task_id, - 'title': task.title, - 'author': task.author, - 'format': task.format, - 'size': task.size, - 'preview': preview, - 'content_type': task.content_type, - 'source': task.source, - 'source_display_name': get_source_display_name(task.source), - 'priority': task.priority, - 'added_time': task.added_time, - 'progress': task.progress, - 'status': task.status, - 'status_message': task.status_message, - 'download_path': task.download_path, - 'user_id': task.user_id, - 'username': task.username, - 'request_id': task.request_id, - 'retry_available': can_retry_download_task(task, retry_status), + "id": task.task_id, + "title": task.title, + "author": task.author, + "format": task.format, + "size": task.size, + "preview": preview, + "content_type": task.content_type, + "source": task.source, + "source_display_name": get_source_display_name(task.source), + "priority": task.priority, + "added_time": task.added_time, + "progress": task.progress, + "status": task.status, + "status_message": task.status_message, + "download_path": task.download_path, + "user_id": task.user_id, + "username": task.username, + "request_id": task.request_id, + "retry_available": can_retry_download_task(task, retry_status), } @@ -524,8 +531,8 @@ def _clear_task_error_state(task: DownloadTask) -> None: def _capture_task_error( task: DownloadTask, *, - message: Optional[str] = None, - exc_type: Optional[str] = None, + message: str | None = None, + exc_type: str | None = None, ) -> None: if isinstance(message, str): normalized = message.strip() @@ -546,7 +553,7 @@ def _format_download_exception_message(exc: Exception) -> str: return f"Download failed: {type(exc).__name__}" -def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: +def _download_task(task_id: str, cancel_flag: Event) -> str | None: """Download a task via appropriate handler, then post-process to ingest.""" try: # Check for cancellation before starting @@ -570,7 +577,7 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: def progress_callback(progress: float) -> None: update_download_progress(task_id, progress) - def status_callback(status: str, message: Optional[str] = None) -> None: + def status_callback(status: str, message: str | None = None) -> None: status_key = status.lower() if status_key == "error": _capture_task_error( @@ -591,7 +598,7 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: # Get the download handler based on the task's source handler = get_handler(task.source) - temp_file: Optional[Path] = None + temp_file: Path | None = None if task.staged_path: staged_file = Path(task.staged_path) @@ -615,7 +622,7 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: temp_file = Path(temp_path) if not run_blocking_io(temp_file.exists): - logger.error(f"Handler returned non-existent path: {temp_path}") + logger.error("Handler returned non-existent path: %s", temp_path) _capture_task_error( task, message=f"Download file missing: {temp_path}", @@ -667,8 +674,6 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: task.staged_path = None _clear_task_error_state(task) - return result - except Exception as e: if cancel_flag.is_set(): logger.info("Task %s: cancelled during error handling", task_id) @@ -683,6 +688,8 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: ) return None + else: + return result def update_download_progress(book_id: str, progress: float) -> None: @@ -696,37 +703,38 @@ def update_download_progress(book_id: str, progress: float) -> None: if last_progress is None or progress != last_progress: _last_activity[book_id] = time.time() _last_progress_value[book_id] = progress - + # Broadcast progress via WebSocket with throttling if ws_manager: current_time = time.time() should_broadcast = False - + with _progress_lock: last_broadcast = _progress_last_broadcast.get(book_id, 0) last_progress = _progress_last_broadcast.get(f"{book_id}_progress", 0) time_elapsed = current_time - last_broadcast - + # Always broadcast at start (0%) or completion (>=99%) - if progress <= 1 or progress >= 99: - should_broadcast = True - # Broadcast if enough time has passed (convert interval from seconds) - elif time_elapsed >= config.DOWNLOAD_PROGRESS_UPDATE_INTERVAL: - should_broadcast = True - # Broadcast on significant progress jumps (>10%) - elif progress - last_progress >= 10: - should_broadcast = True - + should_broadcast = ( + progress <= _PROGRESS_BROADCAST_START_PERCENT + or progress >= _PROGRESS_BROADCAST_COMPLETE_PERCENT + or time_elapsed >= config.DOWNLOAD_PROGRESS_UPDATE_INTERVAL + or progress - last_progress >= _PROGRESS_BROADCAST_MIN_DELTA + ) + if should_broadcast: _progress_last_broadcast[book_id] = current_time _progress_last_broadcast[f"{book_id}_progress"] = progress - + if should_broadcast: task = book_queue.get_task(book_id) task_user_id = task.user_id if task else None - ws_manager.broadcast_download_progress(book_id, progress, 'downloading', user_id=task_user_id) + ws_manager.broadcast_download_progress( + book_id, progress, "downloading", user_id=task_user_id + ) -def update_download_status(book_id: str, status: str, message: Optional[str] = None) -> None: + +def update_download_status(book_id: str, status: str, message: str | None = None) -> None: """Update download status with optional message for UI display.""" status_key = status.lower() try: @@ -752,18 +760,19 @@ def update_download_status(book_id: str, status: str, message: Optional[str] = N if ws_manager: ws_manager.broadcast_status_update(queue_status()) + def cancel_download(book_id: str) -> bool: """Cancel a download.""" result = book_queue.cancel_download(book_id) - + # Broadcast status update via WebSocket if result and ws_manager and ws_manager.is_enabled(): ws_manager.broadcast_status_update(queue_status()) - + return result -def retry_download(book_id: str) -> Tuple[bool, Optional[str]]: +def retry_download(book_id: str) -> tuple[bool, str | None]: """Retry a failed or cancelled download. Request-linked downloads can only be manually retried when cancelled or @@ -794,22 +803,27 @@ def retry_download(book_id: str) -> Tuple[bool, Optional[str]]: return True, None + def set_book_priority(book_id: str, priority: int) -> bool: """Set priority for a queued book (lower = higher priority).""" return book_queue.set_priority(book_id, priority) -def reorder_queue(book_priorities: Dict[str, int]) -> bool: + +def reorder_queue(book_priorities: dict[str, int]) -> bool: """Bulk reorder queue by mapping book_id to new priority.""" return book_queue.reorder_queue(book_priorities) -def get_queue_order() -> List[Dict[str, Any]]: + +def get_queue_order() -> list[dict[str, Any]]: """Get current queue order for display.""" return book_queue.get_queue_order() -def get_active_downloads() -> List[str]: + +def get_active_downloads() -> list[str]: """Get list of currently active downloads.""" return book_queue.get_active_downloads() + def _cleanup_progress_tracking(task_id: str) -> None: """Clean up progress tracking data for a completed/cancelled download.""" with _progress_lock: @@ -875,25 +889,26 @@ def _process_single_download(task_id: str, cancel_flag: Event) -> None: if task: _capture_task_error( task, - message=f"Download failed: {type(e).__name__}: {str(e)}", + message=f"Download failed: {type(e).__name__}: {e!s}", exc_type=type(e).__name__, ) _finalize_download_failure(task_id) else: - logger.info(f"Download cancelled: {task_id}") + logger.info("Download cancelled: %s", task_id) book_queue.update_status(task_id, QueueStatus.CANCELLED) # Broadcast error/cancelled status if ws_manager: ws_manager.broadcast_status_update(queue_status()) + def concurrent_download_loop() -> None: """Main download coordinator using ThreadPoolExecutor for concurrent downloads.""" max_workers = config.MAX_CONCURRENT_DOWNLOADS - logger.info(f"Starting concurrent download loop with {max_workers} workers") + logger.info("Starting concurrent download loop with %s workers", max_workers) with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="Download") as executor: - active_futures: Dict[Future, str] = {} # Track active download futures + active_futures: dict[Future, str] = {} # Track active download futures stalled_tasks: set[str] = set() # Track tasks already cancelled due to stall while True: @@ -911,14 +926,17 @@ def concurrent_download_loop() -> None: # Check for stalled downloads (no activity in STALL_TIMEOUT seconds) current_time = time.time() with _progress_lock: - for future, task_id in list(active_futures.items()): + for _future, task_id in list(active_futures.items()): if task_id in stalled_tasks: continue last_active = _last_activity.get(task_id, current_time) if current_time - last_active > STALL_TIMEOUT: - logger.warning(f"Download stalled for {task_id}, cancelling") + logger.warning("Download stalled for %s, cancelling", task_id) book_queue.cancel_download(task_id) - book_queue.update_status_message(task_id, f"Download stalled (no activity for {STALL_TIMEOUT}s)") + book_queue.update_status_message( + task_id, + f"Download stalled (no activity for {STALL_TIMEOUT}s)", + ) stalled_tasks.add(task_id) # Start new downloads if we have capacity @@ -931,7 +949,7 @@ def concurrent_download_loop() -> None: # Only delay if other downloads are already active if active_futures: stagger_delay = random.uniform(2, 5) - logger.debug(f"Staggering download start by {stagger_delay:.1f}s") + logger.debug("Staggering download start by %.1fs", stagger_delay) time.sleep(stagger_delay) task_id, cancel_flag = next_download @@ -946,8 +964,9 @@ def concurrent_download_loop() -> None: logger.error_trace("Download coordinator loop error: %s", e) time.sleep(COORDINATOR_LOOP_ERROR_RETRY_DELAY) + # Download coordinator thread (started explicitly via start()) -_coordinator_thread: Optional[threading.Thread] = None +_coordinator_thread: threading.Thread | None = None _coordinator_lock = Lock() @@ -964,10 +983,11 @@ def start() -> None: logger.warning("Download coordinator thread is not alive; starting a new one") _coordinator_thread = threading.Thread( - target=concurrent_download_loop, - daemon=True, - name="DownloadCoordinator" + target=concurrent_download_loop, daemon=True, name="DownloadCoordinator" ) _coordinator_thread.start() - logger.info(f"Download coordinator started with {config.MAX_CONCURRENT_DOWNLOADS} concurrent workers") + logger.info( + "Download coordinator started with %s concurrent workers", + config.MAX_CONCURRENT_DOWNLOADS, + ) diff --git a/shelfmark/download/outputs/__init__.py b/shelfmark/download/outputs/__init__.py index bf9df749..db565648 100644 --- a/shelfmark/download/outputs/__init__.py +++ b/shelfmark/download/outputs/__init__.py @@ -1,14 +1,14 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from threading import Event -from typing import Callable, Optional from shelfmark.core.models import DownloadTask -StatusCallback = Callable[[str, Optional[str]], None] -OutputHandler = Callable[[Path, DownloadTask, Event, StatusCallback, bool], Optional[str]] +StatusCallback = Callable[[str, str | None], None] +OutputHandler = Callable[[Path, DownloadTask, Event, StatusCallback, bool], str | None] @dataclass(frozen=True) @@ -48,9 +48,9 @@ def load_output_handlers() -> None: if _OUTPUTS_LOADED: return - from . import booklore # noqa: F401 - from . import email # noqa: F401 - from . import folder # noqa: F401 + from . import booklore as booklore + from . import email as email + from . import folder as folder _OUTPUTS_LOADED = True @@ -65,7 +65,6 @@ def _derive_output_mode(task: DownloadTask) -> str: Prefer the mode captured at queue time. Fall back to current config for legacy tasks that do not have `output_mode` populated. """ - mode = _normalize_output_mode(getattr(task, "output_mode", None)) if mode: return mode @@ -80,7 +79,7 @@ def _derive_output_mode(task: DownloadTask) -> str: return _normalize_output_mode(config.get("BOOKS_OUTPUT_MODE", "folder")) or "folder" -def resolve_output_handler(task: DownloadTask) -> Optional[OutputRegistration]: +def resolve_output_handler(task: DownloadTask) -> OutputRegistration | None: load_output_handlers() desired_mode = _derive_output_mode(task) diff --git a/shelfmark/download/outputs/booklore.py b/shelfmark/download/outputs/booklore.py index 9a8b9dcd..4869f7fe 100644 --- a/shelfmark/download/outputs/booklore.py +++ b/shelfmark/download/outputs/booklore.py @@ -3,24 +3,44 @@ from __future__ import annotations import os from dataclasses import dataclass from pathlib import Path -from threading import Event -from typing import Any, Dict, List, Mapping, Optional +from typing import TYPE_CHECKING, Any import requests import shelfmark.core.config as core_config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.core.utils import is_audiobook as check_audiobook -from shelfmark.download.outputs import register_output -from shelfmark.download.staging import STAGE_COPY, STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir +from shelfmark.download.outputs import StatusCallback, register_output +from shelfmark.download.staging import ( + STAGE_COPY, + STAGE_MOVE, + STAGE_NONE, + build_staging_dir, + get_staging_dir, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + from threading import Event + + from shelfmark.core.models import DownloadTask logger = setup_logger(__name__) BOOKLORE_OUTPUT_MODE = "booklore" BOOKLORE_DESTINATION_LIBRARY = "library" BOOKLORE_DESTINATION_BOOKDROP = "bookdrop" -BOOKLORE_SUPPORTED_EXTENSIONS = {".azw", ".azw3", ".cb7", ".cbr", ".cbz", ".epub", ".fb2", ".mobi", ".pdf"} +BOOKLORE_SUPPORTED_EXTENSIONS = { + ".azw", + ".azw3", + ".cb7", + ".cbr", + ".cbz", + ".epub", + ".fb2", + ".mobi", + ".pdf", +} BOOKLORE_SUPPORTED_FORMATS_LABEL = ", ".join( ext.lstrip(".").upper() for ext in sorted(BOOKLORE_SUPPORTED_EXTENSIONS) ) @@ -43,16 +63,18 @@ class BookloreConfig: refresh_after_upload: bool = False -def _parse_int(value: Any, label: str) -> int: +def _parse_int(value: object, label: str) -> int: if value is None or value == "": - raise BookloreError(f"{label} is required") + msg = f"{label} is required" + raise BookloreError(msg) try: return int(value) except (TypeError, ValueError) as exc: - raise BookloreError(f"{label} must be a number") from exc + msg = f"{label} must be a number" + raise BookloreError(msg) from exc -def _parse_destination(value: Any) -> str: +def _parse_destination(value: object) -> str: normalized = str(value or "").strip().lower() if normalized == BOOKLORE_DESTINATION_BOOKDROP: return BOOKLORE_DESTINATION_BOOKDROP @@ -61,18 +83,21 @@ def _parse_destination(value: Any) -> str: def build_booklore_config( values: Mapping[str, Any], - user_id: Optional[int] = None, + user_id: int | None = None, ) -> BookloreConfig: base_url = str(values.get("BOOKLORE_HOST", "")).strip() username = str(values.get("BOOKLORE_USERNAME", "")).strip() password = values.get("BOOKLORE_PASSWORD", "") or "" if not base_url: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} URL is required") + msg = f"{BOOKLORE_DISPLAY_NAME} URL is required" + raise BookloreError(msg) if not username: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} username is required") + msg = f"{BOOKLORE_DISPLAY_NAME} username is required" + raise BookloreError(msg) if not password: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} password is required") + msg = f"{BOOKLORE_DISPLAY_NAME} password is required" + raise BookloreError(msg) destination = _parse_destination( values.get("BOOKLORE_DESTINATION", BOOKLORE_DESTINATION_LIBRARY) @@ -115,33 +140,43 @@ def build_booklore_config( def booklore_login(booklore_config: BookloreConfig) -> str: url = f"{booklore_config.base_url}/api/v1/auth/login" - payload = {"username": booklore_config.username, "password": booklore_config.password} + payload = { + "username": booklore_config.username, + "password": booklore_config.password, + } try: response = requests.post(url, json=payload, timeout=30, verify=booklore_config.verify_tls) except requests.exceptions.ConnectionError as exc: - raise BookloreError(f"Could not connect to {BOOKLORE_DISPLAY_NAME}") from exc + msg = f"Could not connect to {BOOKLORE_DISPLAY_NAME}" + raise BookloreError(msg) from exc except requests.exceptions.Timeout as exc: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} connection timed out") from exc + msg = f"{BOOKLORE_DISPLAY_NAME} connection timed out" + raise BookloreError(msg) from exc except requests.exceptions.RequestException as exc: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} login failed: {exc}") from exc + msg = f"{BOOKLORE_DISPLAY_NAME} login failed: {exc}" + raise BookloreError(msg) from exc if response.status_code in {401, 403}: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} authentication failed") + msg = f"{BOOKLORE_DISPLAY_NAME} authentication failed" + raise BookloreError(msg) try: response.raise_for_status() except requests.exceptions.HTTPError as exc: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} login failed ({response.status_code})") from exc + msg = f"{BOOKLORE_DISPLAY_NAME} login failed ({response.status_code})" + raise BookloreError(msg) from exc try: data = response.json() except ValueError as exc: - raise BookloreError(f"Invalid {BOOKLORE_DISPLAY_NAME} login response") from exc + msg = f"Invalid {BOOKLORE_DISPLAY_NAME} login response" + raise BookloreError(msg) from exc token = data.get("accessToken") if not token: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} did not return an access token") + msg = f"{BOOKLORE_DISPLAY_NAME} did not return an access token" + raise BookloreError(msg) return token @@ -154,12 +189,14 @@ def booklore_list_libraries(booklore_config: BookloreConfig, token: str) -> list response = requests.get(url, headers=headers, timeout=30, verify=booklore_config.verify_tls) response.raise_for_status() except requests.exceptions.RequestException as exc: - raise BookloreError(f"Failed to fetch {BOOKLORE_DISPLAY_NAME} libraries: {exc}") from exc + msg = f"Failed to fetch {BOOKLORE_DISPLAY_NAME} libraries: {exc}" + raise BookloreError(msg) from exc try: return response.json() except ValueError as exc: - raise BookloreError(f"Invalid {BOOKLORE_DISPLAY_NAME} libraries response") from exc + msg = f"Invalid {BOOKLORE_DISPLAY_NAME} libraries response" + raise BookloreError(msg) from exc def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path: Path) -> None: @@ -168,7 +205,10 @@ def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path: params = None else: url = f"{booklore_config.base_url}/api/v1/files/upload" - params = {"libraryId": booklore_config.library_id, "pathId": booklore_config.path_id} + params = { + "libraryId": booklore_config.library_id, + "pathId": booklore_config.path_id, + } headers = {"Authorization": f"Bearer {token}"} @@ -190,13 +230,17 @@ def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path: if message: message = f": {message[:200]}" status_code = response.status_code if response is not None else "unknown" - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload failed ({status_code}){message}") from exc + msg = f"{BOOKLORE_DISPLAY_NAME} upload failed ({status_code}){message}" + raise BookloreError(msg) from exc except requests.exceptions.ConnectionError as exc: - raise BookloreError(f"Could not connect to {BOOKLORE_DISPLAY_NAME}") from exc + msg = f"Could not connect to {BOOKLORE_DISPLAY_NAME}" + raise BookloreError(msg) from exc except requests.exceptions.Timeout as exc: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload timed out") from exc + msg = f"{BOOKLORE_DISPLAY_NAME} upload timed out" + raise BookloreError(msg) from exc except requests.exceptions.RequestException as exc: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload failed: {exc}") from exc + msg = f"{BOOKLORE_DISPLAY_NAME} upload failed: {exc}" + raise BookloreError(msg) from exc def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> None: @@ -207,14 +251,15 @@ def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> Non response = requests.put(url, headers=headers, timeout=30, verify=booklore_config.verify_tls) response.raise_for_status() except requests.exceptions.RequestException as exc: - raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} refresh failed: {exc}") from exc + msg = f"{BOOKLORE_DISPLAY_NAME} refresh failed: {exc}" + raise BookloreError(msg) from exc def _supports_booklore(task: DownloadTask) -> bool: return not check_audiobook(task.content_type) -def _get_booklore_settings() -> Dict[str, Any]: +def _get_booklore_settings() -> dict[str, Any]: return { "BOOKLORE_HOST": core_config.config.get("BOOKLORE_HOST", ""), "BOOKLORE_USERNAME": core_config.config.get("BOOKLORE_USERNAME", ""), @@ -228,8 +273,8 @@ def _get_booklore_settings() -> Dict[str, Any]: } -def _booklore_format_error(rejected_files: List[Path]) -> str: - rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) +def _booklore_format_error(rejected_files: list[Path]) -> str: + rejected_exts = sorted({f.suffix.lower() for f in rejected_files}) rejected_list = ", ".join(rejected_exts) return ( f"{BOOKLORE_DISPLAY_NAME} does not support {rejected_list}. " @@ -241,9 +286,10 @@ def _post_process_booklore( temp_file: Path, task: DownloadTask, cancel_flag: Event, - status_callback, + status_callback: StatusCallback, + *, preserve_source_on_failure: bool = False, -) -> Optional[str]: +) -> str | None: from shelfmark.download.postprocess.pipeline import ( CustomScriptContext, OutputPlan, @@ -273,7 +319,11 @@ def _post_process_booklore( stage_action = STAGE_NONE if is_managed_workspace_path(temp_file): stage_action = STAGE_COPY if preserve_source_on_failure else STAGE_MOVE - staging_dir = build_staging_dir("booklore", task.task_id) if stage_action != STAGE_NONE else get_staging_dir() + staging_dir = ( + build_staging_dir("booklore", task.task_id) + if stage_action != STAGE_NONE + else get_staging_dir() + ) output_plan = OutputPlan( mode=BOOKLORE_OUTPUT_MODE, @@ -293,7 +343,11 @@ def _post_process_booklore( if not prepared: return None - logger.debug("Task %s: prepared %d file(s) for Booklore upload", task.task_id, len(prepared.files)) + logger.debug( + "Task %s: prepared %d file(s) for Booklore upload", + task.task_id, + len(prepared.files), + ) success = False try: @@ -309,13 +363,20 @@ def _post_process_booklore( return None token = booklore_login(booklore_config) - logger.info("Task %s: uploading %d file(s) to Booklore", task.task_id, len(prepared.files)) + logger.info( + "Task %s: uploading %d file(s) to Booklore", + task.task_id, + len(prepared.files), + ) for index, file_path in enumerate(prepared.files, start=1): if cancel_flag.is_set(): logger.info("Task %s: cancelled during Booklore upload", task.task_id) return None - status_callback("resolving", f"Uploading to {BOOKLORE_DISPLAY_NAME} ({index}/{len(prepared.files)})") + status_callback( + "resolving", + f"Uploading to {BOOKLORE_DISPLAY_NAME} ({index}/{len(prepared.files)})", + ) booklore_upload_file(booklore_config, token, file_path) if booklore_config.refresh_after_upload: @@ -324,9 +385,13 @@ def _post_process_booklore( except BookloreError as e: logger.warning("Task %s: Booklore refresh failed: %s", task.task_id, e) - logger.info("Task %s: uploaded %d file(s) to Booklore", task.task_id, len(prepared.files)) + logger.info( + "Task %s: uploaded %d file(s) to Booklore", + task.task_id, + len(prepared.files), + ) - destination: Optional[Path] + destination: Path | None if len(prepared.files) == 1: destination = prepared.files[0].parent else: @@ -350,11 +415,11 @@ def _post_process_booklore( else BOOKLORE_DESTINATION_LIBRARY ), "library_id": ( - None - if booklore_config.upload_to_bookdrop - else booklore_config.library_id + None if booklore_config.upload_to_bookdrop else booklore_config.library_id ), - "path_id": None if booklore_config.upload_to_bookdrop else booklore_config.path_id, + "path_id": None + if booklore_config.upload_to_bookdrop + else booklore_config.path_id, "refresh_after_upload": bool(booklore_config.refresh_after_upload), } }, @@ -367,7 +432,7 @@ def _post_process_booklore( message = f"Uploaded to {BOOKLORE_DISPLAY_NAME} ({len(prepared.files)} files)" status_callback("complete", message) success = True - return f"booklore://{task.task_id}" + output_path = f"booklore://{task.task_id}" except BookloreError as e: logger.warning("Task %s: Booklore upload failed: %s", task.task_id, e) @@ -377,6 +442,8 @@ def _post_process_booklore( logger.error_trace("Task %s: unexpected error uploading to Booklore: %s", task.task_id, e) status_callback("error", f"{BOOKLORE_DISPLAY_NAME} upload failed: {e}") return None + else: + return output_path finally: cleanup_output_staging( prepared.output_plan, @@ -393,9 +460,10 @@ def process_booklore_output( temp_file: Path, task: DownloadTask, cancel_flag: Event, - status_callback, + status_callback: StatusCallback, + *, preserve_source_on_failure: bool = False, -) -> Optional[str]: +) -> str | None: return _post_process_booklore( temp_file, task, diff --git a/shelfmark/download/outputs/email.py b/shelfmark/download/outputs/email.py index 11f400ef..11e7a6a4 100644 --- a/shelfmark/download/outputs/email.py +++ b/shelfmark/download/outputs/email.py @@ -3,19 +3,30 @@ from __future__ import annotations import mimetypes import smtplib import ssl +from contextlib import suppress from dataclasses import dataclass from email.message import EmailMessage from email.utils import formatdate, make_msgid, parseaddr -from pathlib import Path -from threading import Event -from typing import Any, Dict, Mapping, Optional +from typing import TYPE_CHECKING, Any import shelfmark.core.config as core_config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.core.utils import is_audiobook as check_audiobook from shelfmark.download.outputs import register_output -from shelfmark.download.staging import STAGE_COPY, STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir +from shelfmark.download.staging import ( + STAGE_COPY, + STAGE_MOVE, + STAGE_NONE, + build_staging_dir, + get_staging_dir, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + from pathlib import Path + from threading import Event + + from shelfmark.core.models import DownloadTask logger = setup_logger(__name__) @@ -62,14 +73,18 @@ def build_email_smtp_config(values: Mapping[str, Any]) -> EmailSmtpConfig: security = str(values.get("EMAIL_SMTP_SECURITY", SECURITY_STARTTLS) or "").strip().lower() if security not in ALLOWED_SECURITY: - raise EmailOutputError(f"SMTP security must be one of: {', '.join(sorted(ALLOWED_SECURITY))}") + raise EmailOutputError( + f"SMTP security must be one of: {', '.join(sorted(ALLOWED_SECURITY))}" + ) username = str(values.get("EMAIL_SMTP_USERNAME", "") or "").strip() password = values.get("EMAIL_SMTP_PASSWORD", "") or "" from_addr = str(values.get("EMAIL_FROM", "") or "").strip() subject_template = str(values.get("EMAIL_SUBJECT_TEMPLATE", "{Title}") or "").strip() - timeout_seconds = _parse_int(values.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60), "SMTP timeout (seconds)", minimum=1) + timeout_seconds = _parse_int( + values.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60), "SMTP timeout (seconds)", minimum=1 + ) allow_unverified_tls = bool(values.get("EMAIL_ALLOW_UNVERIFIED_TLS", False)) if not host: @@ -83,7 +98,9 @@ def build_email_smtp_config(values: Mapping[str, Any]) -> EmailSmtpConfig: if username_email and "@" in username_email: from_addr = f"Shelfmark <{username_email}>" else: - raise EmailOutputError("From address is required (or set SMTP username to an email address).") + raise EmailOutputError( + "From address is required (or set SMTP username to an email address)." + ) return EmailSmtpConfig( host=host, @@ -98,7 +115,7 @@ def build_email_smtp_config(values: Mapping[str, Any]) -> EmailSmtpConfig: ) -def _get_email_settings() -> Dict[str, Any]: +def _get_email_settings() -> dict[str, Any]: return { "EMAIL_SMTP_HOST": core_config.config.get("EMAIL_SMTP_HOST", ""), "EMAIL_SMTP_PORT": core_config.config.get("EMAIL_SMTP_PORT", 587), @@ -124,7 +141,7 @@ def _render_subject(template: str, task: DownloadTask) -> str: } try: rendered = template.format(**mapping) - except Exception: + except IndexError, KeyError, ValueError: rendered = template rendered = " ".join(str(rendered).split()).strip() @@ -132,11 +149,8 @@ def _render_subject(template: str, task: DownloadTask) -> str: def _msgid_domain(from_addr: str) -> str: - try: - from_email = parseaddr(from_addr)[1] - domain = (from_email.partition("@")[2] or "").strip().rstrip(">") - except Exception: - domain = "" + from_email = parseaddr(from_addr)[1] + domain = (from_email.partition("@")[2] or "").strip().rstrip(">") return domain or "shelfmark.local" @@ -171,7 +185,7 @@ def compose_email_message( return message -def _create_tls_context(allow_unverified: bool) -> ssl.SSLContext: +def _create_tls_context(*, allow_unverified: bool) -> ssl.SSLContext: context = ssl.create_default_context() if allow_unverified: context.check_hostname = False @@ -181,11 +195,10 @@ def _create_tls_context(allow_unverified: bool) -> ssl.SSLContext: def test_smtp_connection(smtp_config: EmailSmtpConfig) -> None: """Connect and (optionally) authenticate to the SMTP server. Does not send mail.""" - - smtp: Optional[smtplib.SMTP] = None + smtp: smtplib.SMTP | None = None try: if smtp_config.security == SECURITY_SSL: - context = _create_tls_context(smtp_config.allow_unverified_tls) + context = _create_tls_context(allow_unverified=smtp_config.allow_unverified_tls) smtp = smtplib.SMTP_SSL( smtp_config.host, smtp_config.port, @@ -193,12 +206,14 @@ def test_smtp_connection(smtp_config: EmailSmtpConfig) -> None: context=context, ) else: - smtp = smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=smtp_config.timeout_seconds) + smtp = smtplib.SMTP( + smtp_config.host, smtp_config.port, timeout=smtp_config.timeout_seconds + ) smtp.ehlo() if smtp_config.security == SECURITY_STARTTLS: - context = _create_tls_context(smtp_config.allow_unverified_tls) + context = _create_tls_context(allow_unverified=smtp_config.allow_unverified_tls) smtp.starttls(context=context) smtp.ehlo() @@ -210,20 +225,17 @@ def test_smtp_connection(smtp_config: EmailSmtpConfig) -> None: raise EmailOutputError(f"Could not connect to SMTP server: {exc}") from exc finally: if smtp is not None: - try: + with suppress(Exception): smtp.quit() - except Exception: - try: - smtp.close() - except Exception: - pass + with suppress(Exception): + smtp.close() def send_email_message(smtp_config: EmailSmtpConfig, message: EmailMessage) -> None: - smtp: Optional[smtplib.SMTP] = None + smtp: smtplib.SMTP | None = None try: if smtp_config.security == SECURITY_SSL: - context = _create_tls_context(smtp_config.allow_unverified_tls) + context = _create_tls_context(allow_unverified=smtp_config.allow_unverified_tls) smtp = smtplib.SMTP_SSL( smtp_config.host, smtp_config.port, @@ -231,12 +243,14 @@ def send_email_message(smtp_config: EmailSmtpConfig, message: EmailMessage) -> N context=context, ) else: - smtp = smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=smtp_config.timeout_seconds) + smtp = smtplib.SMTP( + smtp_config.host, smtp_config.port, timeout=smtp_config.timeout_seconds + ) smtp.ehlo() if smtp_config.security == SECURITY_STARTTLS: - context = _create_tls_context(smtp_config.allow_unverified_tls) + context = _create_tls_context(allow_unverified=smtp_config.allow_unverified_tls) smtp.starttls(context=context) smtp.ehlo() @@ -250,13 +264,10 @@ def send_email_message(smtp_config: EmailSmtpConfig, message: EmailMessage) -> N raise EmailOutputError(f"Failed to send email: {exc}") from exc finally: if smtp is not None: - try: + with suppress(Exception): smtp.quit() - except Exception: - try: - smtp.close() - except Exception: - pass + with suppress(Exception): + smtp.close() def _supports_email(task: DownloadTask) -> bool: @@ -267,9 +278,10 @@ def _post_process_email( temp_file: Path, task: DownloadTask, cancel_flag: Event, - status_callback, + status_callback: Callable[[str, str | None], None], + *, preserve_source_on_failure: bool = False, -) -> Optional[str]: +) -> str | None: from shelfmark.download.postprocess.pipeline import ( CustomScriptContext, OutputPlan, @@ -309,7 +321,11 @@ def _post_process_email( stage_action = STAGE_NONE if is_managed_workspace_path(temp_file): stage_action = STAGE_COPY if preserve_source_on_failure else STAGE_MOVE - staging_dir = build_staging_dir("email", task.task_id) if stage_action != STAGE_NONE else get_staging_dir() + staging_dir = ( + build_staging_dir("email", task.task_id) + if stage_action != STAGE_NONE + else get_staging_dir() + ) output_plan = OutputPlan( mode=EMAIL_OUTPUT_MODE, @@ -334,7 +350,7 @@ def _post_process_email( limit_mb_raw = core_config.config.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25) try: attachment_limit_mb = int(limit_mb_raw) - except (TypeError, ValueError): + except TypeError, ValueError: attachment_limit_mb = 25 if attachment_limit_mb > 0: @@ -406,7 +422,7 @@ def _post_process_email( status_callback("complete", f"Sent to {label}") success = True - return f"email://{task.task_id}" + output_path = f"email://{task.task_id}" except EmailOutputError as exc: logger.warning("Task %s: email send failed: %s", task.task_id, exc) @@ -416,6 +432,8 @@ def _post_process_email( logger.error_trace("Task %s: unexpected error sending email: %s", task.task_id, exc) status_callback("error", f"Email send failed: {exc}") return None + else: + return output_path finally: cleanup_output_staging( prepared.output_plan, @@ -432,9 +450,10 @@ def process_email_output( temp_file: Path, task: DownloadTask, cancel_flag: Event, - status_callback, + status_callback: Callable[[str, str | None], None], + *, preserve_source_on_failure: bool = False, -) -> Optional[str]: +) -> str | None: return _post_process_email( temp_file, task, diff --git a/shelfmark/download/outputs/folder.py b/shelfmark/download/outputs/folder.py index 61fa46d0..72c73ac2 100644 --- a/shelfmark/download/outputs/folder.py +++ b/shelfmark/download/outputs/folder.py @@ -1,17 +1,19 @@ from __future__ import annotations -import os from dataclasses import dataclass -from pathlib import Path -from threading import Event -from typing import Any, Optional, List +from typing import TYPE_CHECKING, Any import shelfmark.core.config as core_config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.core.utils import is_audiobook as check_audiobook -from shelfmark.download.outputs import register_output -from shelfmark.download.staging import StageAction, STAGE_NONE +from shelfmark.download.outputs import StatusCallback, register_output +from shelfmark.download.staging import STAGE_NONE, StageAction + +if TYPE_CHECKING: + from pathlib import Path + from threading import Event + + from shelfmark.core.models import DownloadTask logger = setup_logger(__name__) @@ -31,7 +33,7 @@ class _ProcessingPlan: allow_archive_extraction: bool stage_action: StageAction staging_dir: Path - hardlink_source: Optional[Path] + hardlink_source: Path | None output_mode: str = FOLDER_OUTPUT_MODE @@ -42,8 +44,8 @@ def _supports_folder_output(task: DownloadTask) -> bool: def _build_processing_plan( temp_file: Path, task: DownloadTask, - status_callback, -) -> Optional[_ProcessingPlan]: + status_callback: StatusCallback, +) -> _ProcessingPlan | None: from shelfmark.download.postprocess.pipeline import ( build_output_plan, get_final_destination, @@ -52,7 +54,7 @@ def _build_processing_plan( from shelfmark.download.postprocess.policy import get_file_organization is_audiobook = check_audiobook(task.content_type) - organization_mode = get_file_organization(is_audiobook) + organization_mode = get_file_organization(is_audiobook=is_audiobook) destination = get_final_destination(task) if not validate_destination(destination, status_callback): @@ -87,9 +89,10 @@ def process_folder_output( temp_file: Path, task: DownloadTask, cancel_flag: Event, - status_callback, + status_callback: StatusCallback, + *, preserve_source_on_failure: bool = False, -) -> Optional[str]: +) -> str | None: """Post-process download to the configured folder destination.""" from shelfmark.download.postprocess.pipeline import ( CustomScriptContext, @@ -97,8 +100,8 @@ def process_folder_output( cleanup_output_staging, is_torrent_source, log_plan_steps, - prepare_output_files, maybe_run_custom_script, + prepare_output_files, record_step, transfer_book_files, ) @@ -128,16 +131,23 @@ def process_folder_output( if not prepared: return None - steps: List[Any] = [] + steps: list[Any] = [] if prepared.output_plan.stage_action != STAGE_NONE: step_name = f"stage_{prepared.output_plan.stage_action}" - record_step(steps, step_name, source=str(temp_file), dest=str(prepared.output_plan.staging_dir)) + record_step( + steps, + step_name, + source=str(temp_file), + dest=str(prepared.output_plan.staging_dir), + ) # Custom script is run post-transfer (see below). # If we staged into TMP_DIR, transfer from the staged path and disable hardlinking. use_hardlink = plan.use_hardlink and prepared.output_plan.stage_action == STAGE_NONE - source_path = plan.hardlink_source if use_hardlink and plan.hardlink_source else prepared.working_path + source_path = ( + plan.hardlink_source if use_hardlink and plan.hardlink_source else prepared.working_path + ) is_torrent = is_torrent_source(source_path, task) usenet_action = core_config.config.get("PROWLARR_USENET_ACTION", "move") @@ -147,7 +157,9 @@ def process_folder_output( # "Move" is implemented as a client-side cleanup after import. preserve_source = is_usenet or preserve_source_on_failure - copy_for_label = is_torrent or preserve_source or prepared.output_plan.stage_action != STAGE_NONE + copy_for_label = ( + is_torrent or preserve_source or prepared.output_plan.stage_action != STAGE_NONE + ) if cancel_flag.is_set(): logger.info("Task %s: cancelled before final transfer", task.task_id) diff --git a/shelfmark/download/permissions_debug.py b/shelfmark/download/permissions_debug.py index 4da53980..c49eb8d2 100644 --- a/shelfmark/download/permissions_debug.py +++ b/shelfmark/download/permissions_debug.py @@ -10,13 +10,40 @@ original error. from __future__ import annotations import os -from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeVar from shelfmark.core.logger import setup_logger +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + logger = setup_logger(__name__) -def _run_io(func, *args, **kwargs): +_T = TypeVar("_T") + + +def _log_path_permissions(probe: Path, label: str) -> None: + """Best-effort logging for one path probe.""" + try: + st = _run_io(probe.stat) + logger.debug( + "Path permissions (%s): path=%s mode=%s owner=%s(%d) group=%s(%d) exists=%s dir=%s", + label, + probe, + oct(st.st_mode & 0o777), + _format_uid(st.st_uid), + st.st_uid, + _format_gid(st.st_gid), + st.st_gid, + _run_io(probe.exists), + _run_io(probe.is_dir), + ) + except Exception as stat_error: + logger.debug("Path permissions (%s): stat failed for %s: %s", label, probe, stat_error) + + +def _run_io[T](func: Callable[..., _T], *args: Any, **kwargs: Any) -> _T: """Best-effort offload for potentially blocking filesystem calls. Keep this module import-cycle safe: `shelfmark.download.fs` imports this module, @@ -57,7 +84,6 @@ def log_path_permission_context(label: str, path: Path) -> None: Only call this from failure paths. """ - try: euid = os.geteuid() if hasattr(os, "geteuid") else None egid = os.getegid() if hasattr(os, "getegid") else None @@ -96,14 +122,18 @@ def log_path_permission_context(label: str, path: Path) -> None: _run_io(probe.is_symlink), ) except Exception as stat_error: - logger.debug("Path permissions (%s): stat failed for %s: %s", label, probe, stat_error) + logger.debug( + "Path permissions (%s): stat failed for %s: %s", + label, + probe, + stat_error, + ) except Exception as context_error: logger.debug("Permission context (%s): failed to collect: %s", label, context_error) def log_transfer_permission_context(label: str, source: Path, dest: Path, error: Exception) -> None: """Log useful permission/ownership context when a file transfer fails.""" - try: euid = os.geteuid() if hasattr(os, "geteuid") else None egid = os.getegid() if hasattr(os, "getegid") else None @@ -122,21 +152,6 @@ def log_transfer_permission_context(label: str, source: Path, dest: Path, error: ) for probe in [source, dest, dest.parent]: - try: - st = _run_io(probe.stat) - logger.debug( - "Path permissions (%s): path=%s mode=%s owner=%s(%d) group=%s(%d) exists=%s dir=%s", - label, - probe, - oct(st.st_mode & 0o777), - _format_uid(st.st_uid), - st.st_uid, - _format_gid(st.st_gid), - st.st_gid, - _run_io(probe.exists), - _run_io(probe.is_dir), - ) - except Exception as stat_error: - logger.debug("Path permissions (%s): stat failed for %s: %s", label, probe, stat_error) + _log_path_permissions(probe, label) except Exception as context_error: logger.debug("Permission context (%s): failed to collect: %s", label, context_error) diff --git a/shelfmark/download/postprocess/__init__.py b/shelfmark/download/postprocess/__init__.py index 03a3eaea..96eae924 100644 --- a/shelfmark/download/postprocess/__init__.py +++ b/shelfmark/download/postprocess/__init__.py @@ -9,3 +9,5 @@ Output handlers live in `shelfmark.download.outputs` and should depend on """ from .router import post_process_download + +__all__ = ["post_process_download"] diff --git a/shelfmark/download/postprocess/custom_script.py b/shelfmark/download/postprocess/custom_script.py index 264238aa..bed80a6d 100644 --- a/shelfmark/download/postprocess/custom_script.py +++ b/shelfmark/download/postprocess/custom_script.py @@ -5,15 +5,20 @@ import os import subprocess from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Optional +from typing import TYPE_CHECKING, Any import shelfmark.core.config as core_config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.download.fs import run_blocking_io from .steps import log_plan_steps, record_step -from .types import PlanStep + +if TYPE_CHECKING: + from collections.abc import Callable + + from shelfmark.core.models import DownloadTask + + from .types import PlanStep logger = setup_logger(__name__) @@ -29,7 +34,6 @@ def resolve_custom_script_target(target_path: Path, destination: Path, path_mode target is not within the destination, fall back to just the filename to avoid leaking unrelated absolute paths. """ - mode = (path_mode or "absolute").strip().lower() if mode != "relative": return target_path @@ -50,7 +54,7 @@ class CustomScriptExecution: destination: Path mode: str phase: str - payload_json: Optional[str] = None + payload_json: str | None = None @dataclass(frozen=True) @@ -66,11 +70,11 @@ class CustomScriptContext: task: DownloadTask phase: str output_mode: str - destination: Optional[Path] = None + destination: Path | None = None final_paths: list[Path] = field(default_factory=list) - target_path: Optional[Path] = None - organization_mode: Optional[str] = None - transfer: Optional[CustomScriptTransferSummary] = None + target_path: Path | None = None + organization_mode: str | None = None + transfer: CustomScriptTransferSummary | None = None output_details: dict[str, Any] = field(default_factory=dict) @@ -81,7 +85,7 @@ def prepare_custom_script_execution( destination: Path, path_mode: str, phase: str, - payload: Optional[dict[str, Any]] = None, + payload: dict[str, Any] | None = None, ) -> CustomScriptExecution: mode = (path_mode or "absolute").strip().lower() if mode != "relative": @@ -103,10 +107,10 @@ def run_custom_script( execution: CustomScriptExecution, *, task_id: str, - status_callback, + status_callback: Callable[[str, str | None], None], timeout_seconds: int = DEFAULT_CUSTOM_SCRIPT_TIMEOUT_SECONDS, ) -> bool: - cwd: Optional[str] = None + cwd: str | None = None if execution.mode == "relative": # Make relative paths unambiguous by running the script from the destination folder. cwd = str(execution.destination) @@ -136,17 +140,18 @@ def run_custom_script( ) if result.stdout: logger.debug("Task %s: custom script stdout: %s", task_id, result.stdout.strip()) - return True except FileNotFoundError: - logger.error("Task %s: custom script not found: %s", task_id, execution.script_path) + logger.exception("Task %s: custom script not found: %s", task_id, execution.script_path) status_callback("error", f"Custom script not found: {execution.script_path}") return False except PermissionError: - logger.error("Task %s: custom script not executable: %s", task_id, execution.script_path) + logger.exception( + "Task %s: custom script not executable: %s", task_id, execution.script_path + ) status_callback("error", f"Custom script not executable: {execution.script_path}") return False except subprocess.TimeoutExpired: - logger.error( + logger.exception( "Task %s: custom script timed out after %ss: %s", task_id, timeout_seconds, @@ -156,7 +161,7 @@ def run_custom_script( return False except subprocess.CalledProcessError as exc: stderr = exc.stderr.strip() if exc.stderr else "No error output" - logger.error( + logger.exception( "Task %s: custom script failed (exit code %s): %s", task_id, exc.returncode, @@ -164,14 +169,16 @@ def run_custom_script( ) status_callback("error", f"Custom script failed: {stderr[:100]}") return False + else: + return True def _choose_custom_script_target( *, - explicit_target: Optional[Path], - destination: Optional[Path], + explicit_target: Path | None, + destination: Path | None, final_paths: list[Path], -) -> Optional[Path]: +) -> Path | None: if explicit_target is not None: return explicit_target @@ -187,7 +194,9 @@ def _choose_custom_script_target( return destination -def _build_custom_script_payload(context: CustomScriptContext, *, target_path: Path) -> dict[str, Any]: +def _build_custom_script_payload( + context: CustomScriptContext, *, target_path: Path +) -> dict[str, Any]: payload: dict[str, Any] = { "version": 1, "phase": context.phase, @@ -233,8 +242,8 @@ def _build_custom_script_payload(context: CustomScriptContext, *, target_path: P def maybe_run_custom_script( context: CustomScriptContext, *, - status_callback, - steps: Optional[list[PlanStep]] = None, + status_callback: Callable[[str, str | None], None], + steps: list[PlanStep] | None = None, ) -> bool: """Run the custom script hook (if configured). @@ -242,7 +251,6 @@ def maybe_run_custom_script( This function is responsible for choosing the script target, building the optional JSON payload, and executing the script. """ - script_path = getattr(core_config.config, "CUSTOM_SCRIPT", None) if not isinstance(script_path, str) or not script_path.strip(): return True @@ -261,7 +269,7 @@ def maybe_run_custom_script( path_mode = core_config.config.get("CUSTOM_SCRIPT_PATH_MODE", "absolute") - payload: Optional[dict[str, Any]] = None + payload: dict[str, Any] | None = None if core_config.config.get("CUSTOM_SCRIPT_JSON_PAYLOAD", False): payload = _build_custom_script_payload(context, target_path=target_path) @@ -293,4 +301,6 @@ def maybe_run_custom_script( ) log_plan_steps(context.task.task_id, steps) - return run_custom_script(execution, task_id=context.task.task_id, status_callback=status_callback) + return run_custom_script( + execution, task_id=context.task.task_id, status_callback=status_callback + ) diff --git a/shelfmark/download/postprocess/destination.py b/shelfmark/download/postprocess/destination.py index 33be8a6d..ed49528f 100644 --- a/shelfmark/download/postprocess/destination.py +++ b/shelfmark/download/postprocess/destination.py @@ -1,32 +1,40 @@ from __future__ import annotations import uuid -from pathlib import Path +from typing import TYPE_CHECKING from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.core.utils import ( get_destination, +) +from shelfmark.core.utils import ( is_audiobook as check_audiobook, ) from shelfmark.download.fs import run_blocking_io from shelfmark.download.permissions_debug import log_path_permission_context from shelfmark.release_sources import get_source +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from shelfmark.core.models import DownloadTask + logger = setup_logger("shelfmark.download.postprocess.pipeline") -def validate_destination(destination: Path, status_callback) -> bool: +def validate_destination( + destination: Path, status_callback: Callable[[str, str | None], None] +) -> bool: """Validate destination path is absolute, exists, and writable.""" - if not destination.is_absolute(): - logger.warning(f"Destination must be absolute: {destination}") + logger.warning("Destination must be absolute: %s", destination) status_callback("error", f"Destination must be absolute: {destination}") return False destination_exists = run_blocking_io(destination.exists) if destination_exists and not run_blocking_io(destination.is_dir): - logger.warning(f"Destination is not a directory: {destination}") + logger.warning("Destination is not a directory: %s", destination) status_callback("error", f"Destination is not a directory: {destination}") return False @@ -35,7 +43,7 @@ def validate_destination(destination: Path, status_callback) -> bool: run_blocking_io(destination.mkdir, parents=True, exist_ok=True) except (OSError, PermissionError) as exc: log_path_permission_context("destination_create", destination) - logger.warning(f"Cannot create destination: {destination} ({exc})") + logger.warning("Cannot create destination: %s (%s)", destination, exc) status_callback("error", f"Cannot create destination: {destination} ({exc})") return False @@ -51,7 +59,7 @@ def validate_destination(destination: Path, status_callback) -> bool: except Exception as exc: logger.debug("Destination write probe path: %s", test_path) log_path_permission_context("destination_write_probe", destination) - logger.warning(f"Destination not writable: {destination} ({exc})") + logger.warning("Destination not writable: %s (%s)", destination, exc) status_callback("error", f"Destination not writable: {destination} ({exc})") return False @@ -60,7 +68,6 @@ def validate_destination(destination: Path, status_callback) -> bool: def get_final_destination(task: DownloadTask) -> Path: """Get final destination directory, with content-type routing support.""" - is_audiobook = check_audiobook(task.content_type) try: @@ -71,4 +78,8 @@ def get_final_destination(task: DownloadTask) -> Path: if override: return override - return get_destination(is_audiobook, user_id=task.user_id, username=task.username) + return get_destination( + is_audiobook=is_audiobook, + user_id=task.user_id, + username=task.username, + ) diff --git a/shelfmark/download/postprocess/pipeline.py b/shelfmark/download/postprocess/pipeline.py index 1b68a2d5..f015350f 100644 --- a/shelfmark/download/postprocess/pipeline.py +++ b/shelfmark/download/postprocess/pipeline.py @@ -18,8 +18,8 @@ implementation stay modular. from __future__ import annotations from .custom_script import ( - CustomScriptExecution, CustomScriptContext, + CustomScriptExecution, CustomScriptTransferSummary, maybe_run_custom_script, prepare_custom_script_execution, @@ -55,13 +55,13 @@ from .workspace import ( ) __all__ = [ + "CustomScriptContext", + "CustomScriptExecution", + "CustomScriptTransferSummary", "OutputPlan", "PlanStep", "PreparedFiles", "TransferPlan", - "CustomScriptExecution", - "CustomScriptContext", - "CustomScriptTransferSummary", "build_metadata_dict", "build_output_plan", "cleanup_output_staging", @@ -75,12 +75,13 @@ __all__ = [ "is_within_tmp_dir", "log_plan_steps", "maybe_run_custom_script", - "prepare_output_files", "prepare_custom_script_execution", + "prepare_output_files", "process_directory", "record_step", - "resolve_hardlink_source", "resolve_custom_script_target", + "resolve_hardlink_source", + "run_custom_script", "safe_cleanup_path", "scan_directory_tree", "should_hardlink", @@ -88,5 +89,4 @@ __all__ = [ "transfer_directory_to_library", "transfer_file_to_library", "validate_destination", - "run_custom_script", ] diff --git a/shelfmark/download/postprocess/policy.py b/shelfmark/download/postprocess/policy.py index ec8ff70f..75716ccd 100644 --- a/shelfmark/download/postprocess/policy.py +++ b/shelfmark/download/postprocess/policy.py @@ -11,18 +11,16 @@ Examples: Implementation note: Keep this module free of dependencies on archive extraction mechanics to avoid circular imports (`archive` is used by the pipeline). + """ from __future__ import annotations -from typing import List - import shelfmark.core.config as core_config -def get_supported_formats() -> List[str]: +def get_supported_formats() -> list[str]: """Get current supported formats from config singleton.""" - formats = core_config.config.get( "SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"], @@ -35,9 +33,8 @@ def get_supported_formats() -> List[str]: return [fmt.lower() for fmt in formats] -def get_supported_audiobook_formats() -> List[str]: +def get_supported_audiobook_formats() -> list[str]: """Get current supported audiobook formats from config singleton.""" - formats = core_config.config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"]) # Handle both list (from MultiSelectField) and comma-separated string (legacy/env) @@ -49,7 +46,6 @@ def get_supported_audiobook_formats() -> List[str]: def get_file_organization(is_audiobook: bool) -> str: """Get the file organization mode for the content type.""" - key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION" mode = core_config.config.get(key, "rename") @@ -68,7 +64,6 @@ def get_file_organization(is_audiobook: bool) -> str: def get_template(is_audiobook: bool, organization_mode: str) -> str: """Get the template for the content type and organization mode.""" - # Determine the correct key based on content type and organization mode if is_audiobook: if organization_mode == "organize": @@ -76,10 +71,7 @@ def get_template(is_audiobook: bool, organization_mode: str) -> str: else: key = "TEMPLATE_AUDIOBOOK_RENAME" else: - if organization_mode == "organize": - key = "TEMPLATE_ORGANIZE" - else: - key = "TEMPLATE_RENAME" + key = "TEMPLATE_ORGANIZE" if organization_mode == "organize" else "TEMPLATE_RENAME" template = core_config.config.get(key, "") diff --git a/shelfmark/download/postprocess/prepare.py b/shelfmark/download/postprocess/prepare.py index fed06b2d..67b403f4 100644 --- a/shelfmark/download/postprocess/prepare.py +++ b/shelfmark/download/postprocess/prepare.py @@ -1,17 +1,26 @@ from __future__ import annotations -from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask -from shelfmark.download.staging import STAGE_COPY, STAGE_NONE, get_staging_dir, stage_path +from shelfmark.download.staging import ( + STAGE_COPY, + STAGE_NONE, + get_staging_dir, + stage_path, +) from .scan import collect_staged_files from .transfer import resolve_hardlink_source from .types import OutputPlan, PreparedFiles from .workspace import cleanup_output_staging, is_managed_workspace_path +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from shelfmark.core.models import DownloadTask + logger = setup_logger("shelfmark.download.postprocess.pipeline") @@ -19,11 +28,10 @@ def build_output_plan( temp_file: Path, task: DownloadTask, output_mode: str, - destination: Optional[Path] = None, - status_callback=None, + destination: Path | None = None, + status_callback: Callable[[str, str | None], None] | None = None, ) -> OutputPlan: """Build an output plan that describes staging behavior for file-based outputs.""" - transfer_plan = resolve_hardlink_source(temp_file, task, destination, status_callback) staging_dir = get_staging_dir() @@ -40,11 +48,12 @@ def prepare_output_files( temp_file: Path, task: DownloadTask, output_mode: str, - status_callback, - destination: Optional[Path] = None, - output_plan: Optional[OutputPlan] = None, + status_callback: Callable[[str, str | None], None] | None, + destination: Path | None = None, + output_plan: OutputPlan | None = None, + *, preserve_source_on_failure: bool = False, -) -> Optional[PreparedFiles]: +) -> PreparedFiles | None: if output_plan is None: output_plan = build_output_plan( temp_file, @@ -56,12 +65,14 @@ def prepare_output_files( working_path = temp_file if output_plan.stage_action != STAGE_NONE: - step_label = "Staging torrent files" if output_plan.stage_action == STAGE_COPY else "Staging files" + step_label = ( + "Staging torrent files" if output_plan.stage_action == STAGE_COPY else "Staging files" + ) status_callback("resolving", step_label) working_path = stage_path(working_path, output_plan.staging_dir, output_plan.stage_action) - can_delete_source_archives = output_plan.stage_action != STAGE_NONE or is_managed_workspace_path( - working_path + can_delete_source_archives = ( + output_plan.stage_action != STAGE_NONE or is_managed_workspace_path(working_path) ) cleanup_archives = can_delete_source_archives and not preserve_source_on_failure diff --git a/shelfmark/download/postprocess/router.py b/shelfmark/download/postprocess/router.py index deec794f..11d198f1 100644 --- a/shelfmark/download/postprocess/router.py +++ b/shelfmark/download/postprocess/router.py @@ -10,14 +10,17 @@ Keeping this separate from `pipeline.py` avoids circular imports: from __future__ import annotations -from pathlib import Path -from threading import Event -from typing import Optional +from typing import TYPE_CHECKING from shelfmark.core.logger import setup_logger from shelfmark.core.models import DownloadTask, SearchMode from shelfmark.download.outputs import resolve_output_handler +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + from threading import Event + logger = setup_logger(__name__) @@ -25,11 +28,11 @@ def post_process_download( temp_file: Path, task: DownloadTask, cancel_flag: Event, - status_callback, + status_callback: Callable[[str, str | None], None], + *, preserve_source_on_failure: bool = False, -) -> Optional[str]: +) -> str | None: """Post-process download using the selected output handler.""" - if task.search_mode is None: logger.warning( "Task %s: missing search_mode; defaulting to Direct mode behavior", @@ -50,7 +53,7 @@ def post_process_download( task, cancel_flag, status_callback, - preserve_source_on_failure, + preserve_source_on_failure=preserve_source_on_failure, ) from shelfmark.download.outputs.folder import process_folder_output @@ -61,5 +64,5 @@ def post_process_download( task, cancel_flag, status_callback, - preserve_source_on_failure, + preserve_source_on_failure=preserve_source_on_failure, ) diff --git a/shelfmark/download/postprocess/scan.py b/shelfmark/download/postprocess/scan.py index 18ef8419..8724f2ea 100644 --- a/shelfmark/download/postprocess/scan.py +++ b/shelfmark/download/postprocess/scan.py @@ -2,33 +2,39 @@ from __future__ import annotations import os from pathlib import Path -from typing import List, Optional, Tuple +from typing import TYPE_CHECKING from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.core.utils import is_audiobook as check_audiobook from shelfmark.download.archive import ArchiveExtractionError, extract_archive, is_archive from shelfmark.download.fs import run_blocking_io from shelfmark.download.permissions_debug import log_path_permission_context from shelfmark.download.postprocess.policy import ( get_supported_audiobook_formats, +) +from shelfmark.download.postprocess.policy import ( get_supported_formats as get_book_formats, ) from shelfmark.download.staging import build_staging_dir +if TYPE_CHECKING: + from collections.abc import Callable + + from shelfmark.core.models import DownloadTask + logger = setup_logger("shelfmark.download.postprocess.pipeline") -def get_supported_formats(content_type: Optional[str] = None) -> List[str]: +def get_supported_formats(content_type: str | None = None) -> list[str]: if check_audiobook(content_type): return get_supported_audiobook_formats() return get_book_formats() -def _format_not_supported_error(rejected_files: List[Path], task: DownloadTask) -> str: +def _format_not_supported_error(rejected_files: list[Path], task: DownloadTask) -> str: content_type = task.content_type file_type_label = "audiobook" if check_audiobook(content_type) else "book" - rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) + rejected_exts = sorted({f.suffix.lower() for f in rejected_files}) rejected_list = ", ".join(rejected_exts) supported_formats = get_supported_formats(content_type) @@ -51,8 +57,9 @@ def extract_archive_files( archive_path: Path, output_dir: Path, task: DownloadTask, + *, cleanup_archive: bool, -) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]: +) -> tuple[list[Path], list[Path], list[Path], str | None]: content_type = task.content_type try: @@ -86,7 +93,12 @@ def extract_archive_files( if not extracted_files: if rejected_files: - return [], rejected_files, cleanup_paths, _format_not_supported_error(rejected_files, task) + return ( + [], + rejected_files, + cleanup_paths, + _format_not_supported_error(rejected_files, task), + ) file_type_label = "audiobook" if check_audiobook(content_type) else "book" return [], rejected_files, cleanup_paths, f"No {file_type_label} files found in archive" @@ -102,11 +114,11 @@ def extract_archive_files( def scan_directory_tree( directory: Path, - content_type: Optional[str], -) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]: + content_type: str | None, +) -> tuple[list[Path], list[Path], list[Path], str | None]: """Scan a directory tree for book files, trackable-but-unsupported files, and archives.""" - try: + def _probe_dir() -> None: # Force a fast error if the dir is missing/inaccessible. with os.scandir(directory) as it: @@ -115,10 +127,10 @@ def scan_directory_tree( run_blocking_io(_probe_dir) except PermissionError as exc: log_path_permission_context("scan_directory", directory) - logger.warning(f"Permission denied scanning directory: {directory} ({exc})") + logger.warning("Permission denied scanning directory: %s (%s)", directory, exc) return [], [], [], f"Permission denied accessing download folder: {directory}" except (FileNotFoundError, NotADirectoryError, OSError) as exc: - logger.warning(f"Cannot access download folder: {directory} ({exc})") + logger.warning("Cannot access download folder: %s (%s)", directory, exc) return [], [], [], f"Cannot access download folder: {directory} ({exc})" supported_formats = get_supported_formats(content_type) @@ -126,11 +138,22 @@ def scan_directory_tree( is_audiobook = check_audiobook(content_type) if is_audiobook: - trackable_exts = {'.m4b', '.mp3', '.m4a', '.flac', '.ogg', '.wma', '.aac', '.wav'} + trackable_exts = {".m4b", ".mp3", ".m4a", ".flac", ".ogg", ".wma", ".aac", ".wav"} else: trackable_exts = { - '.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', - '.doc', '.docx', '.rtf', '.txt', + ".pdf", + ".epub", + ".mobi", + ".azw", + ".azw3", + ".fb2", + ".djvu", + ".cbz", + ".cbr", + ".doc", + ".docx", + ".rtf", + ".txt", } logged_walk_permission_context = False @@ -140,22 +163,19 @@ def scan_directory_tree( if isinstance(error, PermissionError): if not logged_walk_permission_context: - try: - error_path = Path(getattr(error, "filename", "") or str(directory)) - except Exception: - error_path = directory + error_path = Path(getattr(error, "filename", "") or str(directory)) log_path_permission_context("scan_directory_walk", error_path) logged_walk_permission_context = True - logger.debug(f"Skipping inaccessible path during scan: {error}") + logger.debug("Skipping inaccessible path during scan: %s", error) else: - logger.debug(f"Error scanning directory tree: {error}") + logger.debug("Error scanning directory tree: %s", error) - def _walk_tree() -> Tuple[List[Path], List[Path], List[Path]]: - book_files: List[Path] = [] - rejected_files: List[Path] = [] - archive_files: List[Path] = [] + def _walk_tree() -> tuple[list[Path], list[Path], list[Path]]: + book_files: list[Path] = [] + rejected_files: list[Path] = [] + archive_files: list[Path] = [] for root, _, files in os.walk(directory, onerror=onerror): for filename in files: @@ -176,10 +196,10 @@ def scan_directory_tree( book_files, rejected_files, archive_files = run_blocking_io(_walk_tree) except PermissionError as exc: log_path_permission_context("scan_directory_walk", directory) - logger.warning(f"Permission denied scanning directory: {directory} ({exc})") + logger.warning("Permission denied scanning directory: %s (%s)", directory, exc) return [], [], [], f"Permission denied accessing download folder: {directory}" except (FileNotFoundError, NotADirectoryError, OSError) as exc: - logger.warning(f"Cannot access download folder: {directory} ({exc})") + logger.warning("Cannot access download folder: %s (%s)", directory, exc) return [], [], [], f"Cannot access download folder: {directory} ({exc})" return book_files, rejected_files, archive_files, None @@ -188,12 +208,15 @@ def scan_directory_tree( def collect_directory_files( directory: Path, task: DownloadTask, + *, allow_archive_extraction: bool, - status_callback=None, + status_callback: Callable[[str, str | None], None] | None = None, cleanup_archives: bool = False, -) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]: +) -> tuple[list[Path], list[Path], list[Path], str | None]: content_type = task.content_type - book_files, rejected_files, archive_files, scan_error = scan_directory_tree(directory, content_type) + book_files, rejected_files, archive_files, scan_error = scan_directory_tree( + directory, content_type + ) if scan_error: return [], [], [], scan_error @@ -206,7 +229,7 @@ def collect_directory_files( len(book_files), ) if rejected_files: - rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) + rejected_exts = sorted({f.suffix.lower() for f in rejected_files}) logger.debug( "Task %s: also found %d file(s) with unsupported formats: %s", task.task_id, @@ -232,9 +255,9 @@ def collect_directory_files( logger.info("Task %s: extracting %d archive(s)", task.task_id, len(archive_files)) - all_files: List[Path] = [] - all_errors: List[str] = [] - cleanup_paths: List[Path] = [] + all_files: list[Path] = [] + all_errors: list[str] = [] + cleanup_paths: list[Path] = [] for archive in archive_files: extract_dir = build_staging_dir("extract", task.task_id) @@ -267,7 +290,12 @@ def collect_directory_files( return [], rejected_files, cleanup_paths, "; ".join(all_errors) if rejected_files: - return [], rejected_files, cleanup_paths, _format_not_supported_error(rejected_files, task) + return ( + [], + rejected_files, + cleanup_paths, + _format_not_supported_error(rejected_files, task), + ) return [], rejected_files, cleanup_paths, "No book files found in archives" @@ -280,10 +308,11 @@ def collect_directory_files( def collect_staged_files( working_path: Path, task: DownloadTask, + *, allow_archive_extraction: bool, - status_callback, + status_callback: Callable[[str, str | None], None] | None, cleanup_archives: bool, -) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]: +) -> tuple[list[Path], list[Path], list[Path], str | None]: if run_blocking_io(working_path.is_dir): if status_callback: status_callback("resolving", "Processing download folder") @@ -332,11 +361,22 @@ def collect_staged_files( is_audiobook = check_audiobook(task.content_type) if is_audiobook: - trackable_exts = {'.m4b', '.mp3', '.m4a', '.flac', '.ogg', '.wma', '.aac', '.wav'} + trackable_exts = {".m4b", ".mp3", ".m4a", ".flac", ".ogg", ".wma", ".aac", ".wav"} else: trackable_exts = { - '.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', - '.doc', '.docx', '.rtf', '.txt', + ".pdf", + ".epub", + ".mobi", + ".azw", + ".azw3", + ".fb2", + ".djvu", + ".cbz", + ".cbr", + ".doc", + ".docx", + ".rtf", + ".txt", } if suffix in supported_exts: diff --git a/shelfmark/download/postprocess/steps.py b/shelfmark/download/postprocess/steps.py index fda3f8f8..99f54d64 100644 --- a/shelfmark/download/postprocess/steps.py +++ b/shelfmark/download/postprocess/steps.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Any, List - from shelfmark.core.logger import setup_logger from .types import PlanStep @@ -9,11 +7,11 @@ from .types import PlanStep logger = setup_logger("shelfmark.download.postprocess.pipeline") -def record_step(steps: List[PlanStep], name: str, **details: Any) -> None: +def record_step(steps: list[PlanStep], name: str, **details: object) -> None: steps.append(PlanStep(name=name, details=details)) -def log_plan_steps(task_id: str, steps: List[PlanStep]) -> None: +def log_plan_steps(task_id: str, steps: list[PlanStep]) -> None: if not steps: return summary = " -> ".join(step.name for step in steps) diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index b5ef778f..7aaf90a7 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -2,11 +2,10 @@ from __future__ import annotations import os from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import TYPE_CHECKING import shelfmark.core.config as core_config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.core.naming import ( assign_part_numbers, build_library_path, @@ -15,19 +14,28 @@ from shelfmark.core.naming import ( sanitize_filename, ) from shelfmark.core.utils import is_audiobook as check_audiobook -from shelfmark.download.fs import atomic_copy, atomic_hardlink, atomic_move, run_blocking_io +from shelfmark.download.fs import ( + atomic_copy, + atomic_hardlink, + atomic_move, + run_blocking_io, +) from shelfmark.download.postprocess.policy import get_file_organization, get_template from .scan import collect_directory_files, scan_directory_tree from .types import TransferPlan from .workspace import safe_cleanup_path +if TYPE_CHECKING: + from collections.abc import Callable + + from shelfmark.core.models import DownloadTask + logger = setup_logger("shelfmark.download.postprocess.pipeline") def should_hardlink(task: DownloadTask) -> bool: """Check if hardlinking is enabled for this task (Prowlarr torrents only).""" - if task.source != "prowlarr": return False @@ -44,7 +52,6 @@ def should_hardlink(task: DownloadTask) -> bool: return bool(hardlink_enabled) - def build_metadata_dict(task: DownloadTask) -> dict: return { "Author": task.author, @@ -57,7 +64,9 @@ def build_metadata_dict(task: DownloadTask) -> dict: } -def build_file_metadata(task: DownloadTask, source_file: Path, part_number: Optional[str] = None) -> dict: +def build_file_metadata( + task: DownloadTask, source_file: Path, part_number: str | None = None +) -> dict: metadata = build_metadata_dict(task) metadata["OriginalName"] = source_file.stem if part_number is not None: @@ -68,11 +77,10 @@ def build_file_metadata(task: DownloadTask, source_file: Path, part_number: Opti def resolve_hardlink_source( temp_file: Path, task: DownloadTask, - destination: Optional[Path], - status_callback=None, + destination: Path | None, + status_callback: Callable[[str, str | None], None] | None = None, ) -> TransferPlan: """Resolve hardlink eligibility and source path for transfers.""" - use_hardlink = False source_path = temp_file hardlink_enabled = should_hardlink(task) @@ -80,13 +88,18 @@ def resolve_hardlink_source( if hardlink_enabled and task.original_download_path: hardlink_source = Path(task.original_download_path) hardlink_source_exists = run_blocking_io(hardlink_source.exists) - if destination and hardlink_source_exists and run_blocking_io(same_filesystem, hardlink_source, destination): + if ( + destination + and hardlink_source_exists + and run_blocking_io(same_filesystem, hardlink_source, destination) + ): use_hardlink = True source_path = hardlink_source elif hardlink_source_exists: logger.warning( - f"Cannot hardlink: {hardlink_source} and {destination} are on different filesystems. " - "Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination." + "Cannot hardlink: %s and %s are on different filesystems. Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination.", + hardlink_source, + destination, ) if status_callback: status_callback("resolving", "Cannot hardlink (different filesystems), using copy") @@ -101,14 +114,13 @@ def resolve_hardlink_source( def is_torrent_source(source_path: Path, task: DownloadTask) -> bool: """Check if source is the torrent client path (needs copy to preserve seeding).""" - if not task.original_download_path: return False original_path = Path(task.original_download_path) try: return run_blocking_io(source_path.resolve) == run_blocking_io(original_path.resolve) - except (OSError, ValueError): + except OSError, ValueError: try: return os.path.normpath(str(source_path)) == os.path.normpath(str(original_path)) except Exception: @@ -124,11 +136,12 @@ def _max_attempts_for_batch(file_count: int, default: int = 100) -> int: def _transfer_single_file( source_path: Path, dest_path: Path, + *, use_hardlink: bool, is_torrent: bool, preserve_source: bool = False, max_attempts: int = 100, -) -> Tuple[Path, str]: +) -> tuple[Path, str]: if use_hardlink: final_path = atomic_hardlink(source_path, dest_path, max_attempts=max_attempts) try: @@ -145,23 +158,24 @@ def _transfer_single_file( def transfer_book_files( - book_files: List[Path], + book_files: list[Path], destination: Path, task: DownloadTask, + *, use_hardlink: bool, is_torrent: bool, preserve_source: bool = False, - organization_mode: Optional[str] = None, -) -> Tuple[List[Path], Optional[str], Dict[str, int]]: + organization_mode: str | None = None, +) -> tuple[list[Path], str | None, dict[str, int]]: if not book_files: return [], "No book files found", {"hardlink": 0, "copy": 0, "move": 0} is_audiobook = check_audiobook(task.content_type) - organization_mode = organization_mode or get_file_organization(is_audiobook) + organization_mode = organization_mode or get_file_organization(is_audiobook=is_audiobook) max_attempts = _max_attempts_for_batch(len(book_files)) - final_paths: List[Path] = [] - op_counts: Dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0} + final_paths: list[Path] = [] + op_counts: dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0} if organization_mode == "organize": template = get_template(is_audiobook, "organize") @@ -182,14 +196,14 @@ def transfer_book_files( final_path, op = _transfer_single_file( source_file, dest_path, - use_hardlink, - is_torrent, + use_hardlink=use_hardlink, + is_torrent=is_torrent, preserve_source=preserve_source, max_attempts=max_attempts, ) final_paths.append(final_path) op_counts[op] = op_counts.get(op, 0) + 1 - logger.debug(f"{op.capitalize()} to destination: {final_path.name}") + logger.debug("%s to destination: %s", op.capitalize(), final_path.name) else: zero_pad_width = max(len(str(len(book_files))), 2) files_with_parts = assign_part_numbers(book_files, zero_pad_width) @@ -209,14 +223,14 @@ def transfer_book_files( final_path, op = _transfer_single_file( source_file, dest_path, - use_hardlink, - is_torrent, + use_hardlink=use_hardlink, + is_torrent=is_torrent, preserve_source=preserve_source, max_attempts=max_attempts, ) final_paths.append(final_path) op_counts[op] = op_counts.get(op, 0) + 1 - logger.debug(f"{op.capitalize()} to destination: {final_path.name}") + logger.debug("%s to destination: %s", op.capitalize(), final_path.name) return final_paths, None, op_counts @@ -242,14 +256,14 @@ def transfer_book_files( final_path, op = _transfer_single_file( book_file, dest_path, - use_hardlink, - is_torrent, + use_hardlink=use_hardlink, + is_torrent=is_torrent, preserve_source=preserve_source, max_attempts=max_attempts, ) final_paths.append(final_path) op_counts[op] = op_counts.get(op, 0) + 1 - logger.debug(f"{op.capitalize()} to destination: {final_path.name}") + logger.debug("%s to destination: %s", op.capitalize(), final_path.name) return final_paths, None, op_counts @@ -258,11 +272,11 @@ def process_directory( directory: Path, ingest_dir: Path, task: DownloadTask, + *, allow_archive_extraction: bool = True, - use_hardlink: Optional[bool] = None, -) -> Tuple[List[Path], Optional[str]]: + use_hardlink: bool | None = None, +) -> tuple[list[Path], str | None]: """Process staged directory: find book files, extract archives, move to ingest.""" - try: is_torrent = is_torrent_source(directory, task) book_files, _, cleanup_paths, error = collect_directory_files( @@ -299,13 +313,17 @@ def process_directory( for cleanup_path in cleanup_paths: safe_cleanup_path(cleanup_path, task) - return final_paths, None + processed_paths = final_paths except Exception as exc: - logger.error_trace("Task %s: error processing directory %s: %s", task.task_id, directory, exc) + logger.error_trace( + "Task %s: error processing directory %s: %s", task.task_id, directory, exc + ) if not is_torrent_source(directory, task): safe_cleanup_path(directory, task) return [], str(exc) + else: + return processed_paths, None def transfer_file_to_library( @@ -314,25 +332,28 @@ def transfer_file_to_library( template: str, metadata: dict, task: DownloadTask, - temp_file: Optional[Path], - status_callback, + temp_file: Path | None, + status_callback: Callable[[str, str | None], None], + *, use_hardlink: bool, -) -> Optional[str]: +) -> str | None: extension = source_path.suffix.lstrip(".") or task.format template_metadata = dict(metadata) template_metadata.setdefault("OriginalName", source_path.stem) - dest_path = run_blocking_io(build_library_path, library_base, template, template_metadata, extension) + dest_path = run_blocking_io( + build_library_path, library_base, template, template_metadata, extension + ) run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True) is_torrent = is_torrent_source(source_path, task) final_path, op = _transfer_single_file( source_path, dest_path, - use_hardlink, - is_torrent, + use_hardlink=use_hardlink, + is_torrent=is_torrent, max_attempts=_max_attempts_for_batch(1), ) - logger.info(f"Library {op}: {final_path}") + logger.info("Library %s: %s", op, final_path) if use_hardlink and op != "hardlink": logger.warning( "Library hardlink requested but %s used instead for %s", @@ -353,10 +374,11 @@ def transfer_directory_to_library( template: str, metadata: dict, task: DownloadTask, - temp_file: Optional[Path], - status_callback, + temp_file: Path | None, + status_callback: Callable[[str, str | None], None], + *, use_hardlink: bool, -) -> Optional[str]: +) -> str | None: content_type = task.content_type.lower() if task.content_type else None source_files, _, _, scan_error = scan_directory_tree(source_dir, content_type) if scan_error: @@ -367,7 +389,7 @@ def transfer_directory_to_library( return None if not source_files: - logger.warning(f"No supported files in {source_dir.name}") + logger.warning("No supported files in %s", source_dir.name) status_callback("error", "No supported file formats found") if temp_file: safe_cleanup_path(temp_file, task) @@ -383,8 +405,8 @@ def transfer_directory_to_library( run_blocking_io(base_library_path.parent.mkdir, parents=True, exist_ok=True) is_torrent = is_torrent_source(source_dir, task) - transferred_paths: List[Path] = [] - op_counts: Dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0} + transferred_paths: list[Path] = [] + op_counts: dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0} max_attempts = _max_attempts_for_batch(len(source_files)) if len(source_files) == 1: @@ -394,11 +416,11 @@ def transfer_directory_to_library( final_path, op = _transfer_single_file( source_file, dest_path, - use_hardlink, - is_torrent, + use_hardlink=use_hardlink, + is_torrent=is_torrent, max_attempts=max_attempts, ) - logger.debug(f"Library {op}: {source_file.name} -> {final_path}") + logger.debug("Library %s: %s -> %s", op, source_file.name, final_path) transferred_paths.append(final_path) op_counts[op] = op_counts.get(op, 0) + 1 else: @@ -408,23 +430,23 @@ def transfer_directory_to_library( for source_file, part_number in files_with_parts: ext = source_file.suffix.lstrip(".") file_metadata = {**metadata, "PartNumber": part_number} - file_path = run_blocking_io(build_library_path, library_base, template, file_metadata, extension=ext) + file_path = run_blocking_io( + build_library_path, library_base, template, file_metadata, extension=ext + ) run_blocking_io(file_path.parent.mkdir, parents=True, exist_ok=True) final_path, op = _transfer_single_file( source_file, file_path, - use_hardlink, - is_torrent, + use_hardlink=use_hardlink, + is_torrent=is_torrent, max_attempts=max_attempts, ) - logger.debug(f"Library {op}: {source_file.name} -> {final_path}") + logger.debug("Library %s: %s -> %s", op, source_file.name, final_path) transferred_paths.append(final_path) op_counts[op] = op_counts.get(op, 0) + 1 - op_summary = ", ".join( - f"{op}={count}" for op, count in op_counts.items() if count - ) or "none" + op_summary = ", ".join(f"{op}={count}" for op, count in op_counts.items() if count) or "none" logger.info( "Created %d library file(s) in %s (ops: %s)", len(transferred_paths), @@ -444,7 +466,9 @@ def transfer_directory_to_library( safe_cleanup_path(temp_file, task) safe_cleanup_path(source_dir, task) - message = f"Complete ({len(transferred_paths)} files)" if len(transferred_paths) > 1 else "Complete" + message = ( + f"Complete ({len(transferred_paths)} files)" if len(transferred_paths) > 1 else "Complete" + ) status_callback("complete", message) return str(transferred_paths[0]) diff --git a/shelfmark/download/postprocess/types.py b/shelfmark/download/postprocess/types.py index f8f3a7ee..a764475c 100644 --- a/shelfmark/download/postprocess/types.py +++ b/shelfmark/download/postprocess/types.py @@ -1,10 +1,12 @@ from __future__ import annotations from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any -from shelfmark.download.staging import StageAction +if TYPE_CHECKING: + from pathlib import Path + + from shelfmark.download.staging import StageAction @dataclass(frozen=True) @@ -21,19 +23,19 @@ class OutputPlan: stage_action: StageAction staging_dir: Path allow_archive_extraction: bool - transfer_plan: Optional[TransferPlan] = None + transfer_plan: TransferPlan | None = None @dataclass(frozen=True) class PreparedFiles: output_plan: OutputPlan working_path: Path - files: List[Path] - rejected_files: List[Path] - cleanup_paths: List[Path] + files: list[Path] + rejected_files: list[Path] + cleanup_paths: list[Path] @dataclass(frozen=True) class PlanStep: name: str - details: Dict[str, Any] + details: dict[str, Any] diff --git a/shelfmark/download/postprocess/workspace.py b/shelfmark/download/postprocess/workspace.py index 43198905..bb73d935 100644 --- a/shelfmark/download/postprocess/workspace.py +++ b/shelfmark/download/postprocess/workspace.py @@ -2,15 +2,17 @@ from __future__ import annotations import shutil from pathlib import Path -from typing import List, Optional +from typing import TYPE_CHECKING from shelfmark.config import env as env_config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.download.fs import run_blocking_io from shelfmark.download.staging import STAGE_NONE -from .types import OutputPlan +if TYPE_CHECKING: + from shelfmark.core.models import DownloadTask + + from .types import OutputPlan logger = setup_logger("shelfmark.download.postprocess.pipeline") @@ -21,24 +23,28 @@ def _tmp_dir() -> Path: def is_within_tmp_dir(path: Path) -> bool: """Legacy helper: True if path is inside TMP_DIR.""" - # Fast path: avoid `resolve()` (can block on NFS) for obviously-non-TMP paths. # This is a *negative* check only; for potential TMP paths we still resolve to # prevent symlink escapes from being treated as managed. tmp_dir = _tmp_dir() try: - if path.is_absolute() and tmp_dir.is_absolute(): - if path != tmp_dir and tmp_dir not in path.parents: - return False + if ( + path.is_absolute() + and tmp_dir.is_absolute() + and path != tmp_dir + and tmp_dir not in path.parents + ): + return False except Exception: # Fall back to the slower resolve-based check below. pass try: run_blocking_io(path.resolve).relative_to(run_blocking_io(tmp_dir.resolve)) - return True - except (OSError, ValueError): + except OSError, ValueError: return False + else: + return True def is_managed_workspace_path(path: Path) -> bool: @@ -47,23 +53,21 @@ def is_managed_workspace_path(path: Path) -> bool: The managed workspace is `TMP_DIR`. Anything outside it should be treated as read-only for safety (e.g. torrent seeding directories). """ - return is_within_tmp_dir(path) -def _is_original_download(path: Optional[Path], task: DownloadTask) -> bool: +def _is_original_download(path: Path | None, task: DownloadTask) -> bool: if not path or not task.original_download_path: return False try: original = Path(task.original_download_path) return run_blocking_io(path.resolve) == run_blocking_io(original.resolve) - except (OSError, ValueError): + except OSError, ValueError: return False -def safe_cleanup_path(path: Optional[Path], task: DownloadTask) -> None: +def safe_cleanup_path(path: Path | None, task: DownloadTask) -> None: """Remove a temp path only if it is safe and in our managed workspace.""" - if not path or _is_original_download(path, task): return @@ -84,7 +88,7 @@ def cleanup_output_staging( output_plan: OutputPlan, working_path: Path, task: DownloadTask, - cleanup_paths: Optional[List[Path]] = None, + cleanup_paths: list[Path] | None = None, ) -> None: if output_plan.stage_action != STAGE_NONE: cleanup_target = output_plan.staging_dir diff --git a/shelfmark/download/staging.py b/shelfmark/download/staging.py index f566458e..e45a93fd 100644 --- a/shelfmark/download/staging.py +++ b/shelfmark/download/staging.py @@ -2,13 +2,15 @@ from __future__ import annotations import hashlib import shutil -from pathlib import Path -from typing import Literal +from typing import TYPE_CHECKING, Literal from shelfmark.config import env as env_config from shelfmark.core.logger import setup_logger from shelfmark.download.fs import run_blocking_io +if TYPE_CHECKING: + from pathlib import Path + logger = setup_logger(__name__) StageAction = Literal["none", "copy", "move"] @@ -49,7 +51,7 @@ def build_staging_dir(prefix: str | None, task_id: str) -> Path: return staging_dir -def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path: +def stage_file(source_path: Path, task_id: str, *, copy: bool = False) -> Path: """Stage a file for ingest processing. Use copy=True for torrents to preserve seeding.""" staging_dir = get_staging_dir() return stage_path(source_path, staging_dir, STAGE_COPY if copy else STAGE_MOVE) diff --git a/shelfmark/main.py b/shelfmark/main.py index 92e3a21e..e34ed097 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -6,9 +6,12 @@ import os import re import sqlite3 import time +from contextlib import suppress from datetime import datetime, timedelta from functools import wraps -from typing import Any, Dict, Tuple, Union +from importlib import import_module +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, NoReturn from flask import Flask, jsonify, request, send_file, send_from_directory, session from flask_cors import CORS @@ -17,26 +20,48 @@ from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.security import check_password_hash from werkzeug.wrappers import Response -from shelfmark.download import orchestrator as backend -from shelfmark.release_sources import SourceUnavailableError, get_source_display_name -from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE +from shelfmark.api.websocket import ws_manager from shelfmark.config.env import ( - BUILD_VERSION, CONFIG_DIR, CWA_DB_PATH, DEBUG, HIDE_LOCAL_AUTH, - FLASK_HOST, FLASK_PORT, OIDC_AUTO_REDIRECT, RELEASE_VERSION, + BUILD_VERSION, + CONFIG_DIR, + CWA_DB_PATH, + DEBUG, + FLASK_HOST, + FLASK_PORT, + HIDE_LOCAL_AUTH, + OIDC_AUTO_REDIRECT, + RELEASE_VERSION, _is_config_dir_writable, ) -from shelfmark.core.config import config as app_config -from shelfmark.core.logger import setup_logger -from shelfmark.core.models import SearchFilters, QueueStatus, TERMINAL_QUEUE_STATUSES -from shelfmark.core.prefix_middleware import PrefixMiddleware +from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE +from shelfmark.core.activity_view_state_service import ActivityViewStateService from shelfmark.core.auth_modes import ( get_auth_check_admin_status, is_settings_or_onboarding_path, load_active_auth_mode, requires_admin_for_settings_access, ) +from shelfmark.core.config import config as app_config from shelfmark.core.cwa_user_sync import upsert_cwa_user +from shelfmark.core.download_history_service import DownloadHistoryService from shelfmark.core.external_user_linking import upsert_external_user +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import TERMINAL_QUEUE_STATUSES, QueueStatus, SearchFilters +from shelfmark.core.notifications import ( + NotificationContext, + NotificationEvent, + notify_admin, + notify_user, +) +from shelfmark.core.prefix_middleware import PrefixMiddleware +from shelfmark.core.request_helpers import ( + coerce_bool, + emit_ws_event, + get_session_db_user_id, + load_users_request_policy_settings, + normalize_optional_text, + normalize_positive_int, +) from shelfmark.core.request_policy import ( PolicyMode, get_source_content_type_capabilities, @@ -49,31 +74,34 @@ from shelfmark.core.requests_service import ( reopen_failed_request, sync_delivery_states_from_queue_status, ) -from shelfmark.core.activity_view_state_service import ActivityViewStateService -from shelfmark.core.download_history_service import DownloadHistoryService -from shelfmark.core.notifications import NotificationContext, NotificationEvent, notify_admin, notify_user -from shelfmark.core.request_helpers import ( - emit_ws_event, - coerce_bool, - get_session_db_user_id, - load_users_request_policy_settings, - normalize_optional_text, - normalize_positive_int, -) from shelfmark.core.utils import normalize_base_path -from shelfmark.api.websocket import ws_manager +from shelfmark.download import orchestrator as backend +from shelfmark.release_sources import ( + BrowseRecord, + Release, + SourceUnavailableError, + get_source_display_name, +) + +if TYPE_CHECKING: + from shelfmark.metadata_providers import BookMetadata, MetadataProvider logger = setup_logger(__name__) -# Project root is one level up from this package -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -FRONTEND_DIST = os.path.join(PROJECT_ROOT, 'frontend-dist') + +def _raise_runtime_error(message: str) -> NoReturn: + raise RuntimeError(message) + + +# Project root is the repository root above the package directory. +PROJECT_ROOT = Path(__file__).resolve().parent.parent +FRONTEND_DIST = PROJECT_ROOT / "frontend-dist" BASE_PATH = normalize_base_path(app_config.get("URL_BASE", "")) app = Flask(__name__) -app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 # Disable caching -app.config['APPLICATION_ROOT'] = BASE_PATH or '/' +app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 # Disable caching +app.config["APPLICATION_ROOT"] = BASE_PATH or "/" app.wsgi_app = ProxyFix(app.wsgi_app) # type: ignore if BASE_PATH: app.wsgi_app = PrefixMiddleware(app.wsgi_app, BASE_PATH, bypass_paths={"/api/health"}) @@ -81,7 +109,7 @@ if BASE_PATH: # Socket.IO async mode. # We run this app under Gunicorn with a gevent websocket worker (even when DEBUG=true), # so Socket.IO should always use gevent here. -async_mode = 'gevent' +async_mode = "gevent" socketio_cors_allowed_origins = "*" # Initialize Flask-SocketIO with reverse proxy support @@ -97,38 +125,41 @@ socketio = SocketIO( ping_timeout=60, # Time to wait for pong response ping_interval=25, # Send ping every 25 seconds # Allow both websocket and polling for better compatibility - transports=['websocket', 'polling'], + transports=["websocket", "polling"], # Enable CORS for all origins (you can restrict this in production) allow_upgrades=True, # Important for proxies that buffer - http_compression=True + http_compression=True, ) # Initialize WebSocket manager ws_manager.init_app(app, socketio) ws_manager.set_queue_status_fn(backend.queue_status) -logger.info(f"Flask-SocketIO initialized with async_mode='{async_mode}'") +logger.info("Flask-SocketIO initialized with async_mode='%s'", async_mode) logger.info("Socket.IO CORS allowed origins: %s", socketio_cors_allowed_origins) # Ensure all plugins are loaded before starting the download coordinator. # This prevents a race condition where the download loop could try to process # a queued task before its handler (e.g., prowlarr) is registered. try: - import shelfmark.metadata_providers # noqa: F401 - import shelfmark.release_sources # noqa: F401 + import_module("shelfmark.metadata_providers") + import_module("shelfmark.release_sources") logger.debug("Plugin modules loaded successfully") except ImportError as e: - logger.warning(f"Failed to import plugin modules: {e}") + logger.warning("Failed to import plugin modules: %s", e) # Migrate legacy security settings if needed from shelfmark.config.security import _migrate_security_settings + _migrate_security_settings() # Initialize user database and register multi-user routes # If CONFIG_DIR doesn't exist or is read-only, multi-user features will be disabled import os as _os + from shelfmark.core.user_db import UserDB -_user_db_path = _os.path.join(_os.environ.get("CONFIG_DIR", "/config"), "users.db") + +_user_db_path = str(Path(_os.environ.get("CONFIG_DIR", "/config")) / "users.db") user_db: UserDB | None = None download_history_service: DownloadHistoryService | None = None activity_view_state_service: ActivityViewStateService | None = None @@ -137,18 +168,19 @@ try: user_db.initialize() download_history_service = DownloadHistoryService(_user_db_path) activity_view_state_service = ActivityViewStateService(_user_db_path) - import shelfmark.config.users_settings as _ # noqa: F401 - registers users tab - from shelfmark.core.oidc_routes import register_oidc_routes + import_module("shelfmark.config.users_settings") from shelfmark.core.admin_routes import register_admin_routes + from shelfmark.core.oidc_routes import register_oidc_routes from shelfmark.core.self_user_routes import register_self_user_routes + register_oidc_routes(app, user_db) register_admin_routes(app, user_db) register_self_user_routes(app, user_db) except (sqlite3.OperationalError, OSError) as e: logger.warning( - f"User database initialization failed: {e}. " - f"Multi-user authentication features will be disabled. " - f"Ensure CONFIG_DIR ({_os.environ.get('CONFIG_DIR', '/config')}) exists and is writable." + "User database initialization failed: %s. Multi-user authentication features will be disabled. Ensure CONFIG_DIR (%s) exists and is writable.", + e, + _os.environ.get("CONFIG_DIR", "/config"), ) user_db = None download_history_service = None @@ -159,21 +191,24 @@ backend.start() # Rate limiting for login attempts # Structure: {username: {'count': int, 'lockout_until': datetime}} -failed_login_attempts: Dict[str, Dict[str, Any]] = {} +failed_login_attempts: dict[str, dict[str, Any]] = {} MAX_LOGIN_ATTEMPTS = 10 LOCKOUT_DURATION_MINUTES = 30 + def cleanup_old_lockouts() -> None: """Remove expired lockout entries to prevent memory buildup.""" current_time = datetime.now() expired_users = [ - username for username, data in failed_login_attempts.items() - if 'lockout_until' in data and data['lockout_until'] < current_time + username + for username, data in failed_login_attempts.items() + if "lockout_until" in data and data["lockout_until"] < current_time ] for username in expired_users: - logger.info(f"Lockout expired for user: {username}") + logger.info("Lockout expired for user: %s", username) del failed_login_attempts[username] + def is_account_locked(username: str) -> bool: """Check if an account is currently locked due to failed login attempts.""" cleanup_old_lockouts() @@ -181,43 +216,56 @@ def is_account_locked(username: str) -> bool: if username not in failed_login_attempts: return False - lockout_until = failed_login_attempts[username].get('lockout_until') + lockout_until = failed_login_attempts[username].get("lockout_until") return lockout_until is not None and datetime.now() < lockout_until + def record_failed_login(username: str, ip_address: str) -> bool: """Record a failed login attempt and lock account if threshold is reached. Returns True if account is now locked, False otherwise. """ if username not in failed_login_attempts: - failed_login_attempts[username] = {'count': 0} + failed_login_attempts[username] = {"count": 0} - failed_login_attempts[username]['count'] += 1 - count = failed_login_attempts[username]['count'] + failed_login_attempts[username]["count"] += 1 + count = failed_login_attempts[username]["count"] - logger.warning(f"Failed login attempt {count}/{MAX_LOGIN_ATTEMPTS} for user '{username}' from IP {ip_address}") + logger.warning( + "Failed login attempt %s/%s for user '%s' from IP %s", + count, + MAX_LOGIN_ATTEMPTS, + username, + ip_address, + ) if count >= MAX_LOGIN_ATTEMPTS: lockout_until = datetime.now() + timedelta(minutes=LOCKOUT_DURATION_MINUTES) - failed_login_attempts[username]['lockout_until'] = lockout_until - logger.warning(f"Account locked for user '{username}' until {lockout_until.strftime('%Y-%m-%d %H:%M:%S')} due to {count} failed login attempts") + failed_login_attempts[username]["lockout_until"] = lockout_until + logger.warning( + "Account locked for user '%s' until %s due to %s failed login attempts", + username, + lockout_until.strftime("%Y-%m-%d %H:%M:%S"), + count, + ) return True return False + def clear_failed_logins(username: str) -> None: """Clear failed login attempts for a user after successful login.""" if username in failed_login_attempts: del failed_login_attempts[username] - logger.debug(f"Cleared failed login attempts for user: {username}") + logger.debug("Cleared failed login attempts for user: %s", username) def get_client_ip() -> str: """Extract client IP address from request, handling reverse proxy forwarding.""" - ip_address = request.headers.get('X-Forwarded-For', request.remote_addr) or 'unknown' + ip_address = request.headers.get("X-Forwarded-For", request.remote_addr) or "unknown" # X-Forwarded-For can contain multiple IPs, take the first one - if ',' in ip_address: - ip_address = ip_address.split(',')[0].strip() + if "," in ip_address: + ip_address = ip_address.split(",")[0].strip() return ip_address @@ -276,7 +324,7 @@ def _resolve_release_content_type(data: dict[str, Any], source: Any) -> tuple[st for raw_category in categories: try: category_id = int(raw_category) - except (TypeError, ValueError): + except TypeError, ValueError: continue if min_cat <= category_id <= max_cat: return "audiobook", True @@ -320,11 +368,11 @@ def _resolve_policy_mode_for_current_user(*, source: Any, content_type: Any) -> if db_user_id is not None: try: user_settings = user_db.get_user_settings(int(db_user_id)) - except (TypeError, ValueError): + except TypeError, ValueError: user_settings = None effective = merge_request_policy_settings(global_settings, user_settings) - if not coerce_bool(effective.get("REQUESTS_ENABLED"), False): + if not coerce_bool(effective.get("REQUESTS_ENABLED"), default=False): return None resolved_mode = resolve_policy_mode( @@ -345,7 +393,7 @@ def _resolve_policy_mode_for_current_user(*, source: Any, content_type: Any) -> return resolved_mode -def _policy_block_response(mode: PolicyMode): +def _policy_block_response(mode: PolicyMode) -> tuple[Response, int]: logger.debug( "download policy guard user=%s db_user_id=%s mode=%s", session.get("user_id"), @@ -354,19 +402,23 @@ def _policy_block_response(mode: PolicyMode): ) if mode == PolicyMode.BLOCKED: return ( - jsonify({ - "error": "Download not allowed by policy", - "code": "policy_blocked", - "required_mode": PolicyMode.BLOCKED.value, - }), + jsonify( + { + "error": "Download not allowed by policy", + "code": "policy_blocked", + "required_mode": PolicyMode.BLOCKED.value, + } + ), 403, ) return ( - jsonify({ - "error": "Download not allowed by policy", - "code": "policy_requires_request", - "required_mode": mode.value, - }), + jsonify( + { + "error": "Download not allowed by policy", + "code": "policy_requires_request", + "required_mode": mode.value, + } + ), 403, ) @@ -388,7 +440,7 @@ def _resolve_download_user_context( try: target_user_id = int(on_behalf_of_user_id) - except (TypeError, ValueError): + except TypeError, ValueError: return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400) if target_user_id <= 0: @@ -401,16 +453,36 @@ def _resolve_download_user_context( return target_user["id"], target_user["username"], None +def _emit_request_updates(rows: list[dict[str, Any]]) -> None: + """Defer request update emission until the runtime hook is available.""" + _emit_request_update_events(rows) + + +def _resolve_auth_mode_for_routes() -> str: + """Resolve auth mode lazily so tests and runtime patches still take effect.""" + return get_auth_mode() + + +def _queue_release_for_routes(*args: Any, **kwargs: Any) -> Any: + """Queue a release via the current backend instance.""" + return backend.queue_release(*args, **kwargs) + + +def _queue_status_for_routes(user_id: int | None = None) -> dict[str, dict[str, Any]]: + """Read queue status via the current backend instance.""" + return backend.queue_status(user_id=user_id) + + if user_db is not None: try: - from shelfmark.core.request_routes import register_request_routes from shelfmark.core.activity_routes import register_activity_routes + from shelfmark.core.request_routes import register_request_routes register_request_routes( app, user_db, - resolve_auth_mode=lambda: get_auth_mode(), - queue_release=lambda *args, **kwargs: backend.queue_release(*args, **kwargs), + resolve_auth_mode=_resolve_auth_mode_for_routes, + queue_release=_queue_release_for_routes, ws_manager=ws_manager, ) if download_history_service is not None and activity_view_state_service is not None: @@ -419,26 +491,30 @@ if user_db is not None: user_db, activity_view_state_service=activity_view_state_service, download_history_service=download_history_service, - resolve_auth_mode=lambda: get_auth_mode(), - queue_status=lambda user_id=None: backend.queue_status(user_id=user_id), + resolve_auth_mode=_resolve_auth_mode_for_routes, + queue_status=_queue_status_for_routes, sync_request_delivery_states=sync_delivery_states_from_queue_status, - emit_request_updates=lambda rows: _emit_request_update_events(rows), + emit_request_updates=_emit_request_updates, ws_manager=ws_manager, ) except Exception as e: - logger.warning(f"Failed to register request routes: {e}") + logger.warning("Failed to register request routes: %s", e) # Enable CORS in development mode for local frontend development if DEBUG: - CORS(app, resources={ - r"/*": { - "origins": ["http://localhost:5173", "http://127.0.0.1:5173"], - "supports_credentials": True, - "allow_headers": ["Content-Type", "Authorization"], - "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"] - } - }) + CORS( + app, + resources={ + r"/*": { + "origins": ["http://localhost:5173", "http://127.0.0.1:5173"], + "supports_credentials": True, + "allow_headers": ["Content-Type", "Authorization"], + "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + } + }, + ) + # Custom log filter to exclude routine status endpoint polling and WebSocket noise class LogNoiseFilter(logging.Filter): @@ -447,35 +523,41 @@ class LogNoiseFilter(logging.Filter): WebSocket upgrade errors are benign - Flask-SocketIO automatically falls back to polling transport. The error occurs because Werkzeug's built-in server doesn't fully support WebSocket upgrades. """ - def filter(self, record): - message = record.getMessage() if hasattr(record, 'getMessage') else str(record.msg) + + def filter(self, record: logging.LogRecord) -> bool: + message = record.getMessage() if hasattr(record, "getMessage") else str(record.msg) # Exclude GET /api/status requests (polling noise) - if 'GET /api/status' in message: + if "GET /api/status" in message: return False # Exclude WebSocket upgrade errors (benign - falls back to polling) - if 'write() before start_response' in message: + if "write() before start_response" in message: return False # Exclude the Error on request line that precedes WebSocket errors if record.levelno == logging.ERROR: - if 'Error on request:' in message: + if "Error on request:" in message: return False # Filter WebSocket-related AssertionError tracebacks - if hasattr(record, 'exc_info') and record.exc_info: + if hasattr(record, "exc_info") and record.exc_info: exc_type, exc_value = record.exc_info[0], record.exc_info[1] - if exc_type and exc_type.__name__ == 'AssertionError': - if exc_value and 'write() before start_response' in str(exc_value): - return False + if ( + exc_type + and exc_type.__name__ == "AssertionError" + and exc_value + and "write() before start_response" in str(exc_value) + ): + return False return True + # Flask logger app.logger.handlers = logger.handlers app.logger.setLevel(logger.level) # Also handle Werkzeug's logger -werkzeug_logger = logging.getLogger('werkzeug') +werkzeug_logger = logging.getLogger("werkzeug") werkzeug_logger.handlers = logger.handlers werkzeug_logger.setLevel(logger.level) # Add filter to suppress routine status endpoint polling logs and WebSocket upgrade errors @@ -508,7 +590,7 @@ def _load_or_create_secret_key() -> bytes: try: secret_path.parent.mkdir(parents=True, exist_ok=True) secret_path.write_bytes(secret_key) - os.chmod(secret_path, 0o600) + secret_path.chmod(0o600) except OSError as exc: logger.warning( "Failed to persist Flask secret key at %s. Sessions may reset on restart: %s", @@ -520,33 +602,37 @@ def _load_or_create_secret_key() -> bytes: app.config.update( - SECRET_KEY = _load_or_create_secret_key(), - SESSION_COOKIE_HTTPONLY = True, - SESSION_COOKIE_SAMESITE = 'Lax', - SESSION_COOKIE_SECURE = SESSION_COOKIE_SECURE, - SESSION_COOKIE_NAME = SESSION_COOKIE_NAME, - PERMANENT_SESSION_LIFETIME = 604800 # 7 days in seconds + SECRET_KEY=_load_or_create_secret_key(), + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=SESSION_COOKIE_SECURE, + SESSION_COOKIE_NAME=SESSION_COOKIE_NAME, + PERMANENT_SESSION_LIFETIME=604800, # 7 days in seconds ) -logger.info(f"Session cookie secure setting: {SESSION_COOKIE_SECURE} (from env: {SESSION_COOKIE_SECURE_ENV})") -logger.info(f"Session cookie name: {SESSION_COOKIE_NAME}") +logger.info( + "Session cookie secure setting: %s (from env: %s)", + SESSION_COOKIE_SECURE, + SESSION_COOKIE_SECURE_ENV, +) +logger.info("Session cookie name: %s", SESSION_COOKIE_NAME) + @app.before_request -def proxy_auth_middleware(): - """ - Middleware to handle proxy authentication. - +def proxy_auth_middleware() -> Response | tuple[Response, int] | None: + """Middleware to handle proxy authentication. + When AUTH_METHOD is set to "proxy", this middleware automatically authenticates users based on headers set by the reverse proxy. """ auth_mode = get_auth_mode() - + # Only run for proxy auth mode if auth_mode != "proxy": return None - + # Skip for public endpoints that don't need auth - if request.path == '/api/health': + if request.path == "/api/health": return None def get_proxy_header(header_name: str) -> str | None: @@ -573,12 +659,12 @@ def proxy_auth_middleware(): username = get_proxy_header(user_header) if not username: - if request.path.startswith('/api/auth/'): + if request.path.startswith("/api/auth/"): return None - logger.warning(f"Proxy auth enabled but no username found in header '{user_header}'") + logger.warning("Proxy auth enabled but no username found in header '%s'", user_header) return jsonify({"error": "Authentication required. Proxy header not set."}), 401 - + # Resolve admin role for proxy sessions. # If an admin group is configured, derive from groups header. # Otherwise preserve existing DB role for known users and default @@ -590,40 +676,42 @@ def proxy_auth_middleware(): if admin_group_name: groups_header = get_proxy_header(admin_group_header) or "" user_groups_delimiter = "," if "," in groups_header else "|" - user_groups = [g.strip() for g in groups_header.split(user_groups_delimiter) if g.strip()] + user_groups = [ + g.strip() for g in groups_header.split(user_groups_delimiter) if g.strip() + ] is_admin = admin_group_name in user_groups elif user_db is not None: existing_db_user = user_db.get_user(username=username) if existing_db_user: is_admin = existing_db_user.get("role") == "admin" - + # Create or update session - previous_username = session.get('user_id') + previous_username = session.get("user_id") if previous_username and previous_username != username: # Header identity changed mid-session; force reprovision for the new user. - session.pop('db_user_id', None) + session.pop("db_user_id", None) - session['user_id'] = username - session['is_admin'] = is_admin + session["user_id"] = username + session["is_admin"] = is_admin # Provision proxy-authenticated users into users.db for multi-user features. # Re-provision when db_user_id is missing/stale/mismatched to avoid broken # sessions after DB resets or auth-mode transitions. if user_db is not None: - raw_db_user_id = session.get('db_user_id') + raw_db_user_id = session.get("db_user_id") session_db_user = None if raw_db_user_id is not None: try: session_db_user = user_db.get_user(user_id=int(raw_db_user_id)) - except (TypeError, ValueError): + except TypeError, ValueError: session_db_user = None - session_db_username = str(session_db_user.get("username") or "").strip() if session_db_user else "" + session_db_username = ( + str(session_db_user.get("username") or "").strip() if session_db_user else "" + ) needs_db_user_sync = ( - raw_db_user_id is None - or session_db_user is None - or session_db_username != username + raw_db_user_id is None or session_db_user is None or session_db_username != username ) if needs_db_user_sync: @@ -637,17 +725,17 @@ def proxy_auth_middleware(): context="proxy_request", ) if db_user is None: - raise RuntimeError("Unexpected proxy user sync result: no user returned") + _raise_runtime_error("Unexpected proxy user sync result: no user returned") - session['db_user_id'] = db_user["id"] + session["db_user_id"] = db_user["id"] session.permanent = False - - return None - - except Exception as e: - logger.error(f"Proxy auth middleware error: {e}") + except Exception: + logger.exception("Proxy auth middleware error") return jsonify({"error": "Authentication error"}), 500 + else: + return None + @app.after_request def set_security_headers(response: Response) -> Response: @@ -662,9 +750,11 @@ def set_security_headers(response: Response) -> Response: return response -def login_required(f): +def login_required( + f: Callable[..., Response | tuple[Response, int]], +) -> Callable[..., Response | tuple[Response, int]]: @wraps(f) - def decorated_function(*args, **kwargs): + def decorated_function(*args, **kwargs) -> Response | tuple[Response, int]: auth_mode = get_auth_mode() # If no authentication is configured, allow access @@ -673,27 +763,27 @@ def login_required(f): # If CWA mode and database disappeared after startup, return error if auth_mode == "cwa" and CWA_DB_PATH and not CWA_DB_PATH.exists(): - logger.error(f"CWA database at {CWA_DB_PATH} is no longer accessible") + logger.error("CWA database at %s is no longer accessible", CWA_DB_PATH) return jsonify({"error": "Internal Server Error"}), 500 # Check if user has a valid session - if 'user_id' not in session: + if "user_id" not in session: return jsonify({"error": "Unauthorized"}), 401 # Check admin access for settings/onboarding endpoints. if is_settings_or_onboarding_path(request.path): try: - if ( - requires_admin_for_settings_access(request.path, {}) - and not session.get('is_admin', False) + if requires_admin_for_settings_access(request.path, {}) and not session.get( + "is_admin", False ): return jsonify({"error": "Admin access required"}), 403 - except Exception as e: - logger.error(f"Admin access check error: {e}") + except Exception: + logger.exception("Admin access check error") return jsonify({"error": "Internal Server Error"}), 500 return f(*args, **kwargs) + return decorated_function @@ -708,68 +798,68 @@ def _base_href() -> str: def _serve_index_html() -> Response: """Serve index.html with an adjusted base tag for subpath deployments.""" - index_path = os.path.join(FRONTEND_DIST, 'index.html') + index_path = FRONTEND_DIST / "index.html" try: - with open(index_path, 'r', encoding='utf-8') as handle: + with index_path.open(encoding="utf-8") as handle: html = handle.read() except OSError: - return send_from_directory(FRONTEND_DIST, 'index.html') + return send_from_directory(FRONTEND_DIST, "index.html") if BASE_PATH and _BASE_TAG in html: html = html.replace(_BASE_TAG, f'', 1) - return Response(html, mimetype='text/html') + return Response(html, mimetype="text/html") # Serve frontend static files -@app.route('/assets/') +@app.route("/assets/") def serve_frontend_assets(filename: str) -> Response: - """ - Serve static assets from the built frontend. - """ - return send_from_directory(os.path.join(FRONTEND_DIST, 'assets'), filename) + """Serve static assets from the built frontend.""" + return send_from_directory(FRONTEND_DIST / "assets", filename) -@app.route('/') + +@app.route("/") def index() -> Response: - """ - Serve the React frontend application. + """Serve the React frontend application. Authentication is handled by the React app itself. """ return _serve_index_html() -@app.route('/theme-init.js') + +@app.route("/theme-init.js") def theme_init_js() -> Response: """Serve the blocking theme-init script.""" - return send_from_directory(FRONTEND_DIST, 'theme-init.js', mimetype='application/javascript') + return send_from_directory(FRONTEND_DIST, "theme-init.js", mimetype="application/javascript") -@app.route('/logo.png') + +@app.route("/logo.png") def logo() -> Response: - """ - Serve logo from built frontend assets. - """ - return send_from_directory(FRONTEND_DIST, 'logo.png', mimetype='image/png') + """Serve logo from built frontend assets.""" + return send_from_directory(FRONTEND_DIST, "logo.png", mimetype="image/png") -@app.route('/favicon.ico') -@app.route('/favico') + +@app.route("/favicon.ico") +@app.route("/favico") def favicon(_: Any = None) -> Response: - """ - Serve favicon from built frontend assets. - """ - return send_from_directory(FRONTEND_DIST, 'favicon.ico', mimetype='image/vnd.microsoft.icon') + """Serve favicon from built frontend assets.""" + return send_from_directory(FRONTEND_DIST, "favicon.ico", mimetype="image/vnd.microsoft.icon") + if DEBUG: import subprocess + def _stop_gui() -> None: + return None + if app_config.get("USING_EXTERNAL_BYPASSER", False): - _stop_gui = lambda: None + pass else: from shelfmark.bypass.internal_bypasser import _cleanup_orphan_processes as _stop_gui - @app.route('/api/debug', methods=['GET']) + @app.route("/api/debug", methods=["GET"]) @login_required - def debug() -> Union[Response, Tuple[Response, int]]: - """ - This will run the /app/genDebug.sh script, which will generate a debug zip with all the logs + def debug() -> Response | tuple[Response, int]: + """This will run the /app/genDebug.sh script, which will generate a debug zip with all the logs The file will be named /tmp/shelfmark-debug.zip And then return it to the user """ @@ -777,21 +867,23 @@ if DEBUG: logger.info("Debug endpoint called, stopping GUI and generating debug info...") _stop_gui() time.sleep(1) - result = subprocess.run(['/app/genDebug.sh'], capture_output=True, text=True, check=True) + result = subprocess.run( + ["/app/genDebug.sh"], capture_output=True, text=True, check=True + ) if result.returncode != 0: - raise Exception(f"Debug script failed: {result.stderr}") - logger.info(f"Debug script executed: {result.stdout}") - debug_file_path = result.stdout.strip().split('\n')[-1] - if not os.path.exists(debug_file_path): - logger.error(f"Debug zip file not found at: {debug_file_path}") + _raise_runtime_error(f"Debug script failed: {result.stderr}") + logger.info("Debug script executed: %s", result.stdout) + debug_file_path = result.stdout.strip().split("\n")[-1] + if not Path(debug_file_path).exists(): + logger.error("Debug zip file not found at: %s", debug_file_path) return jsonify({"error": "Failed to generate debug information"}), 500 - logger.info(f"Sending debug file: {debug_file_path}") + logger.info("Sending debug file: %s", debug_file_path) return send_file( debug_file_path, - mimetype='application/zip', - download_name=os.path.basename(debug_file_path), - as_attachment=True + mimetype="application/zip", + download_name=Path(debug_file_path).name, + as_attachment=True, ) except subprocess.CalledProcessError as e: logger.error_trace(f"Debug script error: {e}, stdout: {e.stdout}, stderr: {e.stderr}") @@ -800,28 +892,27 @@ if DEBUG: logger.error_trace(f"Debug endpoint error: {e}") return jsonify({"error": str(e)}), 500 - @app.route('/api/restart', methods=['GET']) + @app.route("/api/restart", methods=["GET"]) @login_required - def restart() -> Union[Response, Tuple[Response, int]]: - """ - Restart the application - """ + def restart() -> Response | tuple[Response, int]: + """Restart the application""" os._exit(0) + def _parse_search_filters_from_request() -> SearchFilters: """Parse direct/source browse filters from query parameters.""" return SearchFilters( - isbn=request.args.getlist('isbn'), - author=request.args.getlist('author'), - title=request.args.getlist('title'), - lang=request.args.getlist('lang'), - sort=request.args.get('sort'), - content=request.args.getlist('content'), - format=request.args.getlist('format'), + isbn=request.args.getlist("isbn"), + author=request.args.getlist("author"), + title=request.args.getlist("title"), + lang=request.args.getlist("lang"), + sort=request.args.get("sort"), + content=request.args.getlist("content"), + format=request.args.getlist("format"), ) -def _build_source_query_book(query_text: str, filters: SearchFilters): +def _build_source_query_book(query_text: str, filters: SearchFilters) -> BookMetadata: """Build a synthetic book context for source-native browse searches.""" from shelfmark.metadata_providers import BookMetadata @@ -848,12 +939,9 @@ def _build_source_query_book(query_text: str, filters: SearchFilters): ) -def _serialize_browse_record(record) -> dict: +def _serialize_browse_record(record: BrowseRecord) -> dict: """Serialize a source-native browse record for the frontend.""" - result = { - key: value for key, value in record.__dict__.items() - if value is not None - } + result = {key: value for key, value in record.__dict__.items() if value is not None} preview = result.get("preview") if isinstance(preview, str) and preview: @@ -864,9 +952,10 @@ def _serialize_browse_record(record) -> dict: return result -def _serialize_release(release) -> dict: +def _serialize_release(release: Release) -> dict: """Serialize a release for the frontend, normalizing preview URLs.""" from dataclasses import asdict + from shelfmark.core.utils import transform_cover_url result = asdict(release) @@ -881,11 +970,10 @@ def _serialize_release(release) -> dict: return result -@app.route('/api/releases/download', methods=['POST']) +@app.route("/api/releases/download", methods=["POST"]) @login_required -def api_download_release() -> Union[Response, Tuple[Response, int]]: - """ - Queue a release for download. +def api_download_release() -> Response | tuple[Response, int]: + """Queue a release for download. This endpoint is used when downloading from the ReleaseModal where the frontend already has all the release data from the search results. @@ -900,18 +988,19 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]: Returns: flask.Response: JSON status object indicating success or failure. + """ try: data = request.get_json() if not data: return jsonify({"error": "No data provided"}), 400 - if 'source_id' not in data: + if "source_id" not in data: return jsonify({"error": "source_id is required"}), 400 - if 'source' not in data: + if "source" not in data: return jsonify({"error": "source is required"}), 400 - source = data['source'] + source = data["source"] resolved_content_type, inferred_content_type = _resolve_release_content_type(data, source) policy_mode = _resolve_policy_mode_for_current_user( source=source, @@ -925,10 +1014,10 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]: release_payload = dict(data) release_payload["content_type"] = resolved_content_type - priority = data.get('priority', 0) + priority = data.get("priority", 0) # Per-user download overrides - db_user_id = session.get('db_user_id') - _username = session.get('user_id') + db_user_id = session.get("db_user_id") + _username = session.get("user_id") db_user_id, _username, on_behalf_error = _resolve_download_user_context( db_user_id, _username, @@ -937,8 +1026,10 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]: if on_behalf_error: return on_behalf_error success, error_msg = backend.queue_release( - release_payload, priority, - user_id=db_user_id, username=_username, + release_payload, + priority, + user_id=db_user_id, + username=_username, ) if success: @@ -949,23 +1040,22 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 -@app.route('/api/config', methods=['GET']) +@app.route("/api/config", methods=["GET"]) @login_required -def api_config() -> Union[Response, Tuple[Response, int]]: - """ - Get application configuration for frontend. +def api_config() -> Response | tuple[Response, int]: + """Get application configuration for frontend. Uses the dynamic config singleton to ensure settings changes are reflected without requiring a container restart. """ try: - from shelfmark.metadata_providers import ( - get_provider_sort_options, - get_provider_search_fields, - get_provider_default_sort, - ) from shelfmark.config.env import _is_config_dir_writable from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete + from shelfmark.metadata_providers import ( + get_provider_default_sort, + get_provider_search_fields, + get_provider_sort_options, + ) db_user_id = get_session_db_user_id(session) @@ -990,7 +1080,9 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "", user_id=db_user_id, ) - metadata_ui_provider = configured_metadata_provider or _configured_metadata_provider_audiobook + metadata_ui_provider = ( + configured_metadata_provider or _configured_metadata_provider_audiobook + ) config = { "calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""), @@ -1008,10 +1100,14 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "default_release_source": default_release_source, "default_release_source_audiobook": default_release_source_audiobook, "show_release_source_links": app_config.get("SHOW_RELEASE_SOURCE_LINKS", True), - "show_combined_selector": app_config.get("SHOW_COMBINED_SELECTOR", True, user_id=db_user_id), + "show_combined_selector": app_config.get( + "SHOW_COMBINED_SELECTOR", True, user_id=db_user_id + ), "books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"), "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), - "hardcover_auto_remove_on_download": app_config.get("HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD", True), + "hardcover_auto_remove_on_download": app_config.get( + "HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD", True + ), "download_to_browser_content_types": app_config.get( "DOWNLOAD_TO_BROWSER_CONTENT_TYPES", [], @@ -1020,22 +1116,27 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "settings_enabled": _is_config_dir_writable(), "onboarding_complete": _get_onboarding_complete(), # Default sort orders - "default_sort": app_config.get("AA_DEFAULT_SORT", "relevance"), # For direct mode (Anna's Archive) - "metadata_default_sort": get_provider_default_sort(metadata_ui_provider), # For universal mode + "default_sort": app_config.get( + "AA_DEFAULT_SORT", "relevance" + ), # For direct mode (Anna's Archive) + "metadata_default_sort": get_provider_default_sort( + metadata_ui_provider + ), # For universal mode } return jsonify(config) except Exception as e: logger.error_trace(f"Config error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/health', methods=['GET']) -def api_health() -> Union[Response, Tuple[Response, int]]: - """ - Health check endpoint for container orchestration. + +@app.route("/api/health", methods=["GET"]) +def api_health() -> Response | tuple[Response, int]: + """Health check endpoint for container orchestration. No authentication required. Returns: flask.Response: JSON with status "ok" and optional degraded features. + """ response = {"status": "ok"} @@ -1051,15 +1152,16 @@ def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool, Returns: (is_admin, db_user_id, can_access_status) + """ auth_mode = get_auth_mode() if auth_mode == "none": return True, None, True - if require_authenticated and 'user_id' not in session: + if require_authenticated and "user_id" not in session: return False, None, False - is_admin = bool(session.get('is_admin', False)) + is_admin = bool(session.get("is_admin", False)) if is_admin: return True, None, True @@ -1074,6 +1176,7 @@ def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool, def _queue_status_to_final_activity_status(status: QueueStatus) -> str | None: return status.value if status in TERMINAL_QUEUE_STATUSES else None + def _queue_status_to_notification_event(status: QueueStatus) -> NotificationEvent | None: if status == QueueStatus.COMPLETE: return NotificationEvent.DOWNLOAD_COMPLETE @@ -1082,7 +1185,9 @@ def _queue_status_to_notification_event(status: QueueStatus) -> NotificationEven return None -def _notify_admin_for_terminal_download_status(*, task_id: str, status: QueueStatus, task: Any) -> None: +def _notify_admin_for_terminal_download_status( + *, task_id: str, status: QueueStatus, task: Any +) -> None: event = _queue_status_to_notification_event(status) if event is None: return @@ -1090,7 +1195,7 @@ def _notify_admin_for_terminal_download_status(*, task_id: str, status: QueueSta raw_owner_user_id = getattr(task, "user_id", None) try: owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None - except (TypeError, ValueError): + except TypeError, ValueError: owner_user_id = None content_type = normalize_optional_text(getattr(task, "content_type", None)) @@ -1279,11 +1384,13 @@ def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task: ) -def _task_owned_by_actor(task: Any, *, actor_user_id: int | None, actor_username: str | None) -> bool: +def _task_owned_by_actor( + task: Any, *, actor_user_id: int | None, actor_username: str | None +) -> bool: raw_task_user_id = getattr(task, "user_id", None) try: task_user_id = int(raw_task_user_id) if raw_task_user_id is not None else None - except (TypeError, ValueError): + except TypeError, ValueError: task_user_id = None if actor_user_id is not None and task_user_id is not None: @@ -1338,17 +1445,17 @@ def _emit_request_update_events(updated_requests: list[dict[str, Any]]) -> None: socketio_ref.emit("request_update", payload, to=f"user_{updated['user_id']}") socketio_ref.emit("request_update", payload, to="admins") except Exception as exc: - logger.warning(f"Failed to emit delivery request_update events: {exc}") + logger.warning("Failed to emit delivery request_update events: %s", exc) -@app.route('/api/status', methods=['GET']) +@app.route("/api/status", methods=["GET"]) @login_required -def api_status() -> Union[Response, Tuple[Response, int]]: - """ - Get current download queue status. +def api_status() -> Response | tuple[Response, int]: + """Get current download queue status. Returns: flask.Response: JSON object with queue status. + """ try: is_admin, db_user_id, can_access_status = _resolve_status_scope() @@ -1369,19 +1476,20 @@ def api_status() -> Union[Response, Tuple[Response, int]]: logger.error_trace(f"Status error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/localdownload', methods=['GET']) + +@app.route("/api/localdownload", methods=["GET"]) @login_required -def api_local_download() -> Union[Response, Tuple[Response, int]]: - """ - Download an EPUB file from local storage if available. +def api_local_download() -> Response | tuple[Response, int]: + """Download an EPUB file from local storage if available. Query Parameters: id (str): Book identifier (MD5 hash) Returns: flask.Response: The EPUB file if found, otherwise an error response. + """ - book_id = request.args.get('id', '') + book_id = request.args.get("id", "") if not book_id: return jsonify({"error": "No book ID provided"}), 400 @@ -1402,29 +1510,25 @@ def api_local_download() -> Union[Response, Tuple[Response, int]]: if download_path: return send_file( download_path, - download_name=os.path.basename(download_path), + download_name=Path(download_path).name, as_attachment=True, ) # Book data not found or not available return jsonify({"error": "File not found"}), 404 - file_name = book_info.get_filename() if book_info is not None else os.path.basename(book_id) + file_name = book_info.get_filename() if book_info is not None else Path(book_id).name # Prepare the file for sending to the client data = io.BytesIO(file_data) - return send_file( - data, - download_name=file_name, - as_attachment=True - ) + return send_file(data, download_name=file_name, as_attachment=True) except Exception as e: logger.error_trace(f"Local download error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/covers/', methods=['GET']) -def api_cover(cover_id: str) -> Union[Response, Tuple[Response, int]]: - """ - Serve a cached book cover image. + +@app.route("/api/covers/", methods=["GET"]) +def api_cover(cover_id: str) -> Response | tuple[Response, int]: + """Serve a cached book cover image. This endpoint proxies and caches cover images from external sources. Images are cached to disk for faster subsequent requests. @@ -1437,11 +1541,13 @@ def api_cover(cover_id: str) -> Union[Response, Tuple[Response, int]]: Returns: flask.Response: Binary image data with appropriate Content-Type, or 404. + """ try: import base64 - from shelfmark.core.image_cache import get_image_cache + from shelfmark.config.env import is_covers_cache_enabled + from shelfmark.core.image_cache import get_image_cache # Check if caching is enabled if not is_covers_cache_enabled(): @@ -1453,24 +1559,20 @@ def api_cover(cover_id: str) -> Union[Response, Tuple[Response, int]]: cached = cache.get(cover_id) if cached: image_data, content_type = cached - response = app.response_class( - response=image_data, - status=200, - mimetype=content_type - ) - response.headers['Cache-Control'] = 'public, max-age=86400' - response.headers['X-Cache'] = 'HIT' + response = app.response_class(response=image_data, status=200, mimetype=content_type) + response.headers["Cache-Control"] = "public, max-age=86400" + response.headers["X-Cache"] = "HIT" return response # Cache miss - get URL from query parameter - encoded_url = request.args.get('url') + encoded_url = request.args.get("url") if not encoded_url: return jsonify({"error": "Cover URL not provided"}), 404 try: original_url = base64.urlsafe_b64decode(encoded_url).decode() except Exception as e: - logger.warning(f"Failed to decode cover URL: {e}") + logger.warning("Failed to decode cover URL: %s", e) return jsonify({"error": "Invalid cover URL encoding"}), 400 # Fetch and cache the image @@ -1479,31 +1581,27 @@ def api_cover(cover_id: str) -> Union[Response, Tuple[Response, int]]: return jsonify({"error": "Failed to fetch cover image"}), 404 image_data, content_type = result - response = app.response_class( - response=image_data, - status=200, - mimetype=content_type - ) - response.headers['Cache-Control'] = 'public, max-age=86400' - response.headers['X-Cache'] = 'MISS' - return response - + response = app.response_class(response=image_data, status=200, mimetype=content_type) + response.headers["Cache-Control"] = "public, max-age=86400" + response.headers["X-Cache"] = "MISS" except Exception as e: logger.error_trace(f"Cover fetch error: {e}") return jsonify({"error": str(e)}), 500 + else: + return response -@app.route('/api/download//cancel', methods=['DELETE']) +@app.route("/api/download//cancel", methods=["DELETE"]) @login_required -def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]: - """ - Cancel a download. +def api_cancel_download(book_id: str) -> Response | tuple[Response, int]: + """Cancel a download. Path Parameters: book_id (str): Book identifier to cancel Returns: flask.Response: JSON status indicating success or failure. + """ try: task = backend.book_queue.get_task(book_id) @@ -1513,7 +1611,9 @@ def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]: is_admin, db_user_id, can_access_status = _resolve_status_scope() if not is_admin: if not can_access_status or db_user_id is None: - return jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}), 403 + return jsonify( + {"error": "User identity unavailable", "code": "user_identity_unavailable"} + ), 403 actor_username = session.get("user_id") normalized_actor_username = actor_username if isinstance(actor_username, str) else None @@ -1525,7 +1625,9 @@ def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]: return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403 if getattr(task, "request_id", None) is not None: - return jsonify({"error": "Forbidden", "code": "requested_download_cancel_forbidden"}), 403 + return jsonify( + {"error": "Forbidden", "code": "requested_download_cancel_forbidden"} + ), 403 success = backend.cancel_download(book_id) if success: @@ -1536,9 +1638,9 @@ def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 -@app.route('/api/download//retry', methods=['POST']) +@app.route("/api/download//retry", methods=["POST"]) @login_required -def api_retry_download(book_id: str) -> Union[Response, Tuple[Response, int]]: +def api_retry_download(book_id: str) -> Response | tuple[Response, int]: """Retry a failed download.""" try: task = backend.book_queue.get_task(book_id) @@ -1553,7 +1655,9 @@ def api_retry_download(book_id: str) -> Union[Response, Tuple[Response, int]]: normalized_actor_username = actor_username if isinstance(actor_username, str) else None if not is_admin: if not can_access_status or db_user_id is None: - return jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}), 403 + return jsonify( + {"error": "User identity unavailable", "code": "user_identity_unavailable"} + ), 403 if task is not None: if not _task_owned_by_actor( @@ -1571,22 +1675,24 @@ def api_retry_download(book_id: str) -> Union[Response, Tuple[Response, int]]: if task is not None: task_status = backend.book_queue.get_task_status(book_id) - if ( - getattr(task, "request_id", None) is not None - and not backend.can_retry_download_task(task, task_status) - ): - return jsonify({"error": "Forbidden", "code": "requested_download_retry_forbidden"}), 403 + if getattr( + task, "request_id", None + ) is not None and not backend.can_retry_download_task(task, task_status): + return jsonify( + {"error": "Forbidden", "code": "requested_download_retry_forbidden"} + ), 403 success, error = backend.retry_download(book_id) else: assert history_row is not None request_id = normalize_positive_int(history_row.get("request_id")) retry_payload = history_row.get("retry_payload") final_status = history_row.get("final_status") - if ( - request_id is not None - and not download_history_service.is_retry_available(history_row) + if request_id is not None and not download_history_service.is_retry_available( + history_row ): - return jsonify({"error": "Forbidden", "code": "requested_download_retry_forbidden"}), 403 + return jsonify( + {"error": "Forbidden", "code": "requested_download_retry_forbidden"} + ), 403 success, error = backend.retry_persisted_download( retry_payload, final_status=final_status, @@ -1604,11 +1710,10 @@ def api_retry_download(book_id: str) -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 -@app.route('/api/queue//priority', methods=['PUT']) +@app.route("/api/queue//priority", methods=["PUT"]) @login_required -def api_set_priority(book_id: str) -> Union[Response, Tuple[Response, int]]: - """ - Set priority for a queued book. +def api_set_priority(book_id: str) -> Response | tuple[Response, int]: + """Set priority for a queued book. Path Parameters: book_id (str): Book identifier @@ -1618,15 +1723,16 @@ def api_set_priority(book_id: str) -> Union[Response, Tuple[Response, int]]: Returns: flask.Response: JSON status indicating success or failure. + """ try: data = request.get_json() - if not data or 'priority' not in data: + if not data or "priority" not in data: return jsonify({"error": "Priority not provided"}), 400 - - priority = int(data['priority']) + + priority = int(data["priority"]) success = backend.set_book_priority(book_id, priority) - + if success: return jsonify({"status": "updated", "book_id": book_id, "priority": priority}) return jsonify({"error": "Failed to update priority or book not found"}), 404 @@ -1636,34 +1742,35 @@ def api_set_priority(book_id: str) -> Union[Response, Tuple[Response, int]]: logger.error_trace(f"Set priority error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/queue/reorder', methods=['POST']) + +@app.route("/api/queue/reorder", methods=["POST"]) @login_required -def api_reorder_queue() -> Union[Response, Tuple[Response, int]]: - """ - Bulk reorder queue by setting new priorities. +def api_reorder_queue() -> Response | tuple[Response, int]: + """Bulk reorder queue by setting new priorities. Request Body: book_priorities (dict): Mapping of book_id to new priority Returns: flask.Response: JSON status indicating success or failure. + """ try: data = request.get_json() - if not data or 'book_priorities' not in data: + if not data or "book_priorities" not in data: return jsonify({"error": "book_priorities not provided"}), 400 - - book_priorities = data['book_priorities'] + + book_priorities = data["book_priorities"] if not isinstance(book_priorities, dict): return jsonify({"error": "book_priorities must be a dictionary"}), 400 - + # Validate all priorities are integers for book_id, priority in book_priorities.items(): if not isinstance(priority, int): return jsonify({"error": f"Invalid priority for book {book_id}"}), 400 - + success = backend.reorder_queue(book_priorities) - + if success: return jsonify({"status": "reordered", "updated_count": len(book_priorities)}) return jsonify({"error": "Failed to reorder queue"}), 500 @@ -1671,14 +1778,15 @@ def api_reorder_queue() -> Union[Response, Tuple[Response, int]]: logger.error_trace(f"Reorder queue error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/queue/order', methods=['GET']) + +@app.route("/api/queue/order", methods=["GET"]) @login_required -def api_queue_order() -> Union[Response, Tuple[Response, int]]: - """ - Get current queue order for display. +def api_queue_order() -> Response | tuple[Response, int]: + """Get current queue order for display. Returns: flask.Response: JSON array of queued books with their order and priorities. + """ try: queue_order = backend.get_queue_order() @@ -1687,14 +1795,15 @@ def api_queue_order() -> Union[Response, Tuple[Response, int]]: logger.error_trace(f"Queue order error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/downloads/active', methods=['GET']) + +@app.route("/api/downloads/active", methods=["GET"]) @login_required -def api_active_downloads() -> Union[Response, Tuple[Response, int]]: - """ - Get list of currently active downloads. +def api_active_downloads() -> Response | tuple[Response, int]: + """Get list of currently active downloads. Returns: flask.Response: JSON array of active download book IDs. + """ try: active_downloads = backend.get_active_downloads() @@ -1703,56 +1812,60 @@ def api_active_downloads() -> Union[Response, Tuple[Response, int]]: logger.error_trace(f"Active downloads error: {e}") return jsonify({"error": str(e)}), 500 + @app.errorhandler(404) -def not_found_error(error: Exception) -> Union[Response, Tuple[Response, int]]: - """ - Handle 404 (Not Found) errors. +def not_found_error(error: Exception) -> Response | tuple[Response, int]: + """Handle 404 (Not Found) errors. Args: error (HTTPException): The 404 error raised by Flask. Returns: flask.Response: JSON error message with 404 status. + """ - logger.warning(f"404 error: {request.url} : {error}") + logger.warning("404 error: %s : %s", request.url, error) return jsonify({"error": "Resource not found"}), 404 + @app.errorhandler(500) -def internal_error(error: Exception) -> Union[Response, Tuple[Response, int]]: - """ - Handle 500 (Internal Server) errors. +def internal_error(error: Exception) -> Response | tuple[Response, int]: + """Handle 500 (Internal Server) errors. Args: error (HTTPException): The 500 error raised by Flask. Returns: flask.Response: JSON error message with 500 status. + """ logger.error_trace(f"500 error: {error}") return jsonify({"error": "Internal server error"}), 500 -def _failed_login_response(username: str, ip_address: str) -> Tuple[Response, int]: + +def _failed_login_response(username: str, ip_address: str) -> tuple[Response, int]: """Handle a failed login attempt by recording it and returning the appropriate response.""" is_now_locked = record_failed_login(username, ip_address) if is_now_locked: - return jsonify({ - "error": f"Account locked due to {MAX_LOGIN_ATTEMPTS} failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes." - }), 429 + return jsonify( + { + "error": f"Account locked due to {MAX_LOGIN_ATTEMPTS} failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes." + } + ), 429 - attempts_remaining = MAX_LOGIN_ATTEMPTS - failed_login_attempts[username]['count'] + attempts_remaining = MAX_LOGIN_ATTEMPTS - failed_login_attempts[username]["count"] if attempts_remaining <= 5: - return jsonify({ - "error": f"Invalid username or password. {attempts_remaining} attempts remaining." - }), 401 + return jsonify( + {"error": f"Invalid username or password. {attempts_remaining} attempts remaining."} + ), 401 return jsonify({"error": "Invalid username or password."}), 401 -@app.route('/api/auth/login', methods=['POST']) -def api_login() -> Union[Response, Tuple[Response, int]]: - """ - Login endpoint that validates credentials and creates a session. +@app.route("/api/auth/login", methods=["POST"]) +def api_login() -> Response | tuple[Response, int]: + """Login endpoint that validates credentials and creates a session. Supports both built-in credentials and CWA database authentication. Includes rate limiting: 10 failed attempts = 30 minute lockout. @@ -1763,6 +1876,7 @@ def api_login() -> Union[Response, Tuple[Response, int]]: Returns: flask.Response: JSON with success status or error message. + """ try: ip_address = get_client_ip() @@ -1777,35 +1891,43 @@ def api_login() -> Union[Response, Tuple[Response, int]]: if auth_mode == "oidc" and HIDE_LOCAL_AUTH: return jsonify({"error": "Local authentication is disabled"}), 403 - username = data.get('username', '').strip() - password = data.get('password', '') - remember_me = data.get('remember_me', False) + username = data.get("username", "").strip() + password = data.get("password", "") + remember_me = data.get("remember_me", False) if not username or not password: return jsonify({"error": "Username and password are required"}), 400 # Check if account is locked due to failed login attempts if is_account_locked(username): - lockout_until = failed_login_attempts[username].get('lockout_until') + lockout_until = failed_login_attempts[username].get("lockout_until") remaining_time = (lockout_until - datetime.now()).total_seconds() / 60 - logger.warning(f"Login attempt blocked for locked account '{username}' from IP {ip_address}") - return jsonify({ - "error": f"Account temporarily locked due to multiple failed login attempts. Try again in {int(remaining_time)} minutes." - }), 429 + logger.warning( + "Login attempt blocked for locked account '%s' from IP %s", username, ip_address + ) + return jsonify( + { + "error": f"Account temporarily locked due to multiple failed login attempts. Try again in {int(remaining_time)} minutes." + } + ), 429 # If no authentication is configured, authentication always succeeds if auth_mode == "none": - session['user_id'] = username + session["user_id"] = username session.permanent = remember_me clear_failed_logins(username) - logger.info(f"Login successful for user '{username}' from IP {ip_address} (no auth configured)") + logger.info( + "Login successful for user '%s' from IP %s (no auth configured)", + username, + ip_address, + ) return jsonify({"success": True}) # Password authentication (builtin and OIDC modes) # OIDC mode also allows password login as a fallback so admins don't get locked out if auth_mode in ("builtin", "oidc"): if user_db is None: - logger.error(f"User database not available for {auth_mode} auth") + logger.error("User database not available for %s auth", auth_mode) return jsonify({"error": "Authentication service unavailable"}), 503 try: db_user = user_db.get_user(username=username) @@ -1815,16 +1937,25 @@ def api_login() -> Union[Response, Tuple[Response, int]]: # Authenticate against DB user if db_user: - if not db_user.get("password_hash") or not check_password_hash(db_user["password_hash"], password): + if not db_user.get("password_hash") or not check_password_hash( + db_user["password_hash"], password + ): return _failed_login_response(username, ip_address) is_admin = db_user["role"] == "admin" - session['user_id'] = username - session['db_user_id'] = db_user["id"] - session['is_admin'] = is_admin + session["user_id"] = username + session["db_user_id"] = db_user["id"] + session["is_admin"] = is_admin session.permanent = remember_me clear_failed_logins(username) - logger.info(f"Login successful for user '{username}' from IP {ip_address} ({auth_mode} auth, is_admin={is_admin}, remember_me={remember_me})") + logger.info( + "Login successful for user '%s' from IP %s (%s auth, is_admin=%s, remember_me=%s)", + username, + ip_address, + auth_mode, + is_admin, + remember_me, + ) return jsonify({"success": True}) return _failed_login_response(username, ip_address) @@ -1837,7 +1968,7 @@ def api_login() -> Union[Response, Tuple[Response, int]]: if auth_mode == "cwa": # Verify database still exists (it was validated at startup) if not CWA_DB_PATH or not CWA_DB_PATH.exists(): - logger.error(f"CWA database at {CWA_DB_PATH} is no longer accessible") + logger.error("CWA database at %s is no longer accessible", CWA_DB_PATH) return jsonify({"error": "Database configuration error"}), 500 try: @@ -1871,13 +2002,19 @@ def api_login() -> Union[Response, Tuple[Response, int]]: db_user_id = db_user["id"] # Successful authentication - create session and clear failed attempts - session['user_id'] = username - session['is_admin'] = is_admin + session["user_id"] = username + session["is_admin"] = is_admin if db_user_id is not None: - session['db_user_id'] = db_user_id + session["db_user_id"] = db_user_id session.permanent = remember_me clear_failed_logins(username) - logger.info(f"Login successful for user '{username}' from IP {ip_address} (CWA auth, is_admin={is_admin}, remember_me={remember_me})") + logger.info( + "Login successful for user '%s' from IP %s (CWA auth, is_admin=%s, remember_me=%s)", + username, + ip_address, + is_admin, + remember_me, + ) return jsonify({"success": True}) except Exception as e: @@ -1891,63 +2028,67 @@ def api_login() -> Union[Response, Tuple[Response, int]]: logger.error_trace(f"Login error: {e}") return jsonify({"error": "Login failed"}), 500 -@app.route('/api/auth/logout', methods=['POST']) -def api_logout() -> Union[Response, Tuple[Response, int]]: - """ - Logout endpoint that clears the session. + +@app.route("/api/auth/logout", methods=["POST"]) +def api_logout() -> Response | tuple[Response, int]: + """Logout endpoint that clears the session. For proxy auth, returns the logout URL if configured. - + Returns: flask.Response: JSON with success status and optional logout_url. + """ try: auth_mode = get_auth_mode() ip_address = get_client_ip() - username = session.get('user_id', 'unknown') + username = session.get("user_id", "unknown") session.clear() - logger.info(f"Logout successful for user '{username}' from IP {ip_address}") - + logger.info("Logout successful for user '%s' from IP %s", username, ip_address) + # For proxy auth, include logout URL if configured if auth_mode == "proxy": logout_url = app_config.get("PROXY_AUTH_LOGOUT_URL", "") if logout_url: return jsonify({"success": True, "logout_url": logout_url}) - + return jsonify({"success": True}) except Exception as e: logger.error_trace(f"Logout error: {e}") return jsonify({"error": "Logout failed"}), 500 -@app.route('/api/auth/check', methods=['GET']) -def api_auth_check() -> Union[Response, Tuple[Response, int]]: - """ - Check if user has a valid session. + +@app.route("/api/auth/check", methods=["GET"]) +def api_auth_check() -> Response | tuple[Response, int]: + """Check if user has a valid session. Returns: flask.Response: JSON with authentication status, whether auth is required, which auth mode is active, and whether user has admin privileges. + """ try: auth_mode = get_auth_mode() # If no authentication is configured, access is allowed (full admin) if auth_mode == "none": - return jsonify({ - "authenticated": True, - "auth_required": False, - "auth_mode": "none", - "is_admin": True - }) + return jsonify( + { + "authenticated": True, + "auth_required": False, + "auth_mode": "none", + "is_admin": True, + } + ) # Check if user has a valid session - is_authenticated = 'user_id' in session + is_authenticated = "user_id" in session is_admin = get_auth_check_admin_status(auth_mode, {}, session) display_name = None - if is_authenticated and session.get('db_user_id') and user_db is not None: + if is_authenticated and session.get("db_user_id") and user_db is not None: try: - db_user = user_db.get_user(user_id=session['db_user_id']) + db_user = user_db.get_user(user_id=session["db_user_id"]) if db_user: display_name = db_user.get("display_name") or None except Exception: @@ -1958,10 +2099,10 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]: "auth_required": True, "auth_mode": auth_mode, "is_admin": is_admin if is_authenticated else False, - "username": session.get('user_id') if is_authenticated else None, + "username": session.get("user_id") if is_authenticated else None, "display_name": display_name, } - + # Add logout URL for proxy auth if configured if auth_mode == "proxy" and app_config.get("PROXY_AUTH_USER_HEADER", ""): logout_url = app_config.get("PROXY_AUTH_LOGOUT_URL", "") @@ -1977,33 +2118,35 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]: response_data["hide_local_auth"] = True if OIDC_AUTO_REDIRECT: response_data["oidc_auto_redirect"] = True - + return jsonify(response_data) except Exception as e: logger.error_trace(f"Auth check error: {e}") - return jsonify({ - "authenticated": False, - "auth_required": True, - "auth_mode": "unknown", - "is_admin": False - }) + return jsonify( + { + "authenticated": False, + "auth_required": True, + "auth_mode": "unknown", + "is_admin": False, + } + ) -@app.route('/api/metadata/providers', methods=['GET']) +@app.route("/api/metadata/providers", methods=["GET"]) @login_required -def api_metadata_providers() -> Union[Response, Tuple[Response, int]]: - """ - Get list of available metadata providers. +def api_metadata_providers() -> Response | tuple[Response, int]: + """Get list of available metadata providers. Returns: flask.Response: JSON with list of providers and their status. + """ try: from shelfmark.metadata_providers import ( get_configured_provider_name, - list_providers, get_provider, get_provider_kwargs, + list_providers, ) app_config.refresh() @@ -2044,26 +2187,28 @@ def api_metadata_providers() -> Union[Response, Tuple[Response, int]]: providers.append(provider_info) - return jsonify({ - "providers": providers, - "configured_provider": configured_metadata_provider or None, - "configured_provider_audiobook": configured_audiobook_metadata_provider or None, - "configured_provider_combined": configured_combined_metadata_provider or None, - }) + return jsonify( + { + "providers": providers, + "configured_provider": configured_metadata_provider or None, + "configured_provider_audiobook": configured_audiobook_metadata_provider or None, + "configured_provider_combined": configured_combined_metadata_provider or None, + } + ) except Exception as e: logger.error_trace(f"Metadata providers error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/metadata/config', methods=['GET']) +@app.route("/api/metadata/config", methods=["GET"]) @login_required -def api_metadata_config() -> Union[Response, Tuple[Response, int]]: +def api_metadata_config() -> Response | tuple[Response, int]: """Return provider-specific metadata search config for the active session.""" try: from shelfmark.metadata_providers import ( get_configured_provider_name, - get_provider_capabilities, get_provider, + get_provider_capabilities, get_provider_default_sort, get_provider_kwargs, get_provider_search_fields, @@ -2072,8 +2217,8 @@ def api_metadata_config() -> Union[Response, Tuple[Response, int]]: ) app_config.refresh() - content_type = request.args.get('content_type', 'ebook').strip() - provider_name = request.args.get('provider', '').strip() + content_type = request.args.get("content_type", "ebook").strip() + provider_name = request.args.get("provider", "").strip() db_user_id = get_session_db_user_id(session) @@ -2085,16 +2230,18 @@ def api_metadata_config() -> Union[Response, Tuple[Response, int]]: ) if not provider_name: - return jsonify({ - "provider": None, - "display_name": None, - "enabled": False, - "available": False, - "search_fields": [], - "capabilities": [], - "sort_options": [{"value": "relevance", "label": "Most relevant"}], - "default_sort": "relevance", - }) + return jsonify( + { + "provider": None, + "display_name": None, + "enabled": False, + "available": False, + "search_fields": [], + "capabilities": [], + "sort_options": [{"value": "relevance", "label": "Most relevant"}], + "default_sort": "relevance", + } + ) if not is_provider_registered(provider_name): return jsonify({"error": f"Unknown metadata provider: {provider_name}"}), 400 @@ -2105,26 +2252,27 @@ def api_metadata_config() -> Union[Response, Tuple[Response, int]]: provider_enabled = app_config.get(enabled_key, False) is True provider_available = provider.is_available() - return jsonify({ - "provider": provider_name, - "display_name": provider.display_name, - "enabled": provider_enabled, - "available": provider_available, - "search_fields": get_provider_search_fields(provider_name), - "capabilities": get_provider_capabilities(provider_name), - "sort_options": get_provider_sort_options(provider_name), - "default_sort": get_provider_default_sort(provider_name, user_id=db_user_id), - }) + return jsonify( + { + "provider": provider_name, + "display_name": provider.display_name, + "enabled": provider_enabled, + "available": provider_available, + "search_fields": get_provider_search_fields(provider_name), + "capabilities": get_provider_capabilities(provider_name), + "sort_options": get_provider_sort_options(provider_name), + "default_sort": get_provider_default_sort(provider_name, user_id=db_user_id), + } + ) except Exception as e: logger.error_trace(f"Metadata config error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/metadata/search', methods=['GET']) +@app.route("/api/metadata/search", methods=["GET"]) @login_required -def api_metadata_search() -> Union[Response, Tuple[Response, int]]: - """ - Search for books using the configured metadata provider. +def api_metadata_search() -> Response | tuple[Response, int]: + """Search for books using the configured metadata provider. Query Parameters: query (str): Search query (required) @@ -2134,37 +2282,39 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: Returns: flask.Response: JSON with list of books from metadata provider. + """ try: + from dataclasses import asdict + from shelfmark.metadata_providers import ( - get_provider, + CheckboxSearchField, + MetadataSearchOptions, + NumberSearchField, + SortOrder, get_configured_provider, + get_provider, get_provider_kwargs, is_provider_enabled, is_provider_registered, - MetadataSearchOptions, - SortOrder, - CheckboxSearchField, - NumberSearchField, ) - from dataclasses import asdict - query = request.args.get('query', '').strip() - content_type = request.args.get('content_type', 'ebook').strip() - provider_name = request.args.get('provider', '').strip() + query = request.args.get("query", "").strip() + content_type = request.args.get("content_type", "ebook").strip() + provider_name = request.args.get("provider", "").strip() try: - limit = min(int(request.args.get('limit', 40)), 100) + limit = min(int(request.args.get("limit", 40)), 100) except ValueError: limit = 40 try: - page = max(1, int(request.args.get('page', 1))) + page = max(1, int(request.args.get("page", 1))) except ValueError: page = 1 # Parse sort parameter - sort_value = request.args.get('sort', 'relevance').lower() + sort_value = request.args.get("sort", "relevance").lower() try: sort_order = SortOrder(sort_value) except ValueError: @@ -2174,15 +2324,19 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: if provider_name: if not is_provider_registered(provider_name): - return jsonify({ - "error": f"Unknown metadata provider: {provider_name}", - "message": f"Unknown metadata provider: {provider_name}", - }), 400 + return jsonify( + { + "error": f"Unknown metadata provider: {provider_name}", + "message": f"Unknown metadata provider: {provider_name}", + } + ), 400 if not is_provider_enabled(provider_name): - return jsonify({ - "error": f"Metadata provider '{provider_name}' is not enabled", - "message": f"{provider_name} is not enabled. Enable it in Settings first.", - }), 503 + return jsonify( + { + "error": f"Metadata provider '{provider_name}' is not enabled", + "message": f"{provider_name} is not enabled. Enable it in Settings first.", + } + ), 503 kwargs = get_provider_kwargs(provider_name) provider = get_provider(provider_name, **kwargs) @@ -2190,19 +2344,23 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: provider = get_configured_provider(content_type=content_type, user_id=db_user_id) if not provider: - return jsonify({ - "error": "No metadata provider configured", - "message": "No metadata provider configured. Enable one in Settings." - }), 503 + return jsonify( + { + "error": "No metadata provider configured", + "message": "No metadata provider configured. Enable one in Settings.", + } + ), 503 if not provider.is_available(): - return jsonify({ - "error": f"Metadata provider '{provider.name}' is not available", - "message": f"{provider.display_name} is not available. Check configuration in Settings." - }), 503 + return jsonify( + { + "error": f"Metadata provider '{provider.name}' is not available", + "message": f"{provider.display_name} is not available. Check configuration in Settings.", + } + ), 503 # Extract custom search field values from query params - fields: Dict[str, Any] = {} + fields: dict[str, Any] = {} for search_field in provider.search_fields: value = request.args.get(search_field.key) if value is not None: @@ -2211,12 +2369,10 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: if value != "": # Parse value based on field type if isinstance(search_field, CheckboxSearchField): - fields[search_field.key] = value.lower() in ('true', '1', 'yes', 'on') + fields[search_field.key] = value.lower() in ("true", "1", "yes", "on") elif isinstance(search_field, NumberSearchField): - try: + with suppress(ValueError): fields[search_field.key] = int(value) - except ValueError: - pass # Skip invalid numbers else: fields[search_field.key] = value @@ -2224,7 +2380,9 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: if not query and not fields: return jsonify({"error": "Either 'query' or search field values are required"}), 400 - options = MetadataSearchOptions(query=query, limit=limit, page=page, sort=sort_order, fields=fields) + options = MetadataSearchOptions( + query=query, limit=limit, page=page, sort=sort_order, fields=fields + ) search_result = provider.search_paginated(options) # Convert BookMetadata objects to dicts @@ -2232,10 +2390,11 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: # Transform cover_url to local proxy URLs when caching is enabled from shelfmark.core.utils import transform_cover_url + for book_dict in books_data: - if book_dict.get('cover_url'): + if book_dict.get("cover_url"): cache_id = f"{book_dict['provider']}_{book_dict['provider_id']}" - book_dict['cover_url'] = transform_cover_url(book_dict['cover_url'], cache_id) + book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id) response_data = { "books": books_data, @@ -2255,7 +2414,7 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 -@app.route('/api/metadata/field-options', methods=['GET']) +@app.route("/api/metadata/field-options", methods=["GET"]) @login_required def api_metadata_field_options() -> Response: """Return dynamic search-field options for a metadata provider.""" @@ -2267,10 +2426,10 @@ def api_metadata_field_options() -> Response: is_provider_registered, ) - field_key = request.args.get('field', '').strip() - provider_name = request.args.get('provider', '').strip() - content_type = request.args.get('content_type', 'ebook').strip() - query_text = request.args.get('query', '').strip() + field_key = request.args.get("field", "").strip() + provider_name = request.args.get("provider", "").strip() + content_type = request.args.get("content_type", "ebook").strip() + query_text = request.args.get("query", "").strip() if not field_key: return jsonify({"options": []}) @@ -2292,11 +2451,11 @@ def api_metadata_field_options() -> Response: options = provider.get_search_field_options(field_key, query=query_text or None) return jsonify({"options": options}) except Exception as e: - logger.warning(f"Metadata field options endpoint error: {e}") + logger.warning("Metadata field options endpoint error: %s", e) return jsonify({"options": []}) -def _resolve_metadata_provider(provider_name: str): +def _resolve_metadata_provider(provider_name: str) -> MetadataProvider: """Validate, instantiate and return a ready metadata provider. Raises appropriate HTTP-friendly exceptions on failure. @@ -2319,11 +2478,10 @@ def _resolve_metadata_provider(provider_name: str): return prov -@app.route('/api/metadata/book//', methods=['GET']) +@app.route("/api/metadata/book//", methods=["GET"]) @login_required -def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Response, int]]: - """ - Get detailed book information from a metadata provider. +def api_metadata_book(provider: str, book_id: str) -> Response | tuple[Response, int]: + """Get detailed book information from a metadata provider. Path Parameters: provider (str): Provider name (e.g., "hardcover", "openlibrary") @@ -2331,6 +2489,7 @@ def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Resp Returns: flask.Response: JSON with book details. + """ try: from dataclasses import asdict @@ -2345,9 +2504,10 @@ def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Resp # Transform cover_url to local proxy URL when caching is enabled from shelfmark.core.utils import transform_cover_url - if book_dict.get('cover_url'): + + if book_dict.get("cover_url"): cache_id = f"{provider}_{book_id}" - book_dict['cover_url'] = transform_cover_url(book_dict['cover_url'], cache_id) + book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id) return jsonify(book_dict) except ValueError as e: @@ -2359,11 +2519,18 @@ def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Resp return jsonify({"error": str(e)}), 500 -def _handle_target_errors(fallback_message: str): +def _handle_target_errors( + fallback_message: str, +) -> Callable[ + [Callable[..., Response | tuple[Response, int]]], Callable[..., Response | tuple[Response, int]] +]: """Decorator that wraps a metadata-target route with standard error handling.""" - def decorator(fn): + + def decorator( + fn: Callable[..., Response | tuple[Response, int]], + ) -> Callable[..., Response | tuple[Response, int]]: @wraps(fn) - def wrapper(*args, **kwargs): + def wrapper(*args, **kwargs) -> Response | tuple[Response, int]: try: return fn(*args, **kwargs) except (NotImplementedError, ValueError) as e: @@ -2373,23 +2540,25 @@ def _handle_target_errors(fallback_message: str): except Exception as e: logger.error_trace(f"{fallback_message}: {e}") return jsonify({"error": fallback_message}), 500 + return wrapper + return decorator -@app.route('/api/metadata/book///targets', methods=['GET']) +@app.route("/api/metadata/book///targets", methods=["GET"]) @login_required @_handle_target_errors("Failed to load book targets") -def api_metadata_book_targets(provider: str, book_id: str) -> Union[Response, Tuple[Response, int]]: +def api_metadata_book_targets(provider: str, book_id: str) -> Response | tuple[Response, int]: """Get provider-managed list/status targets for a specific book.""" prov = _resolve_metadata_provider(provider) return jsonify({"options": prov.get_book_targets(book_id)}) -@app.route('/api/metadata/book//targets/batch', methods=['POST']) +@app.route("/api/metadata/book//targets/batch", methods=["POST"]) @login_required @_handle_target_errors("Failed to load book targets") -def api_metadata_book_targets_batch(provider: str) -> Union[Response, Tuple[Response, int]]: +def api_metadata_book_targets_batch(provider: str) -> Response | tuple[Response, int]: """Get provider-managed list/status targets for multiple books.""" prov = _resolve_metadata_provider(provider) @@ -2403,10 +2572,12 @@ def api_metadata_book_targets_batch(provider: str) -> Union[Response, Tuple[Resp return jsonify({"results": prov.get_book_targets_batch(book_ids)}) -@app.route('/api/metadata/book///targets', methods=['PUT']) +@app.route("/api/metadata/book///targets", methods=["PUT"]) @login_required @_handle_target_errors("Failed to update book targets") -def api_metadata_book_targets_update(provider: str, book_id: str) -> Union[Response, Tuple[Response, int]]: +def api_metadata_book_targets_update( + provider: str, book_id: str +) -> Response | tuple[Response, int]: """Set whether a book belongs to a provider-managed list or shelf.""" prov = _resolve_metadata_provider(provider) @@ -2430,11 +2601,10 @@ def api_metadata_book_targets_update(provider: str, book_id: str) -> Union[Respo return jsonify(response) -@app.route('/api/releases', methods=['GET']) +@app.route("/api/releases", methods=["GET"]) @login_required -def api_releases() -> Union[Response, Tuple[Response, int]]: - """ - Search for downloadable releases of a book. +def api_releases() -> Response | tuple[Response, int]: + """Search for downloadable releases of a book. This endpoint takes book metadata and searches available release sources (e.g., Anna's Archive, Libgen) for downloadable files. @@ -2446,14 +2616,17 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: Returns: flask.Response: JSON with list of available releases. + """ try: from dataclasses import asdict + + from shelfmark.core.search_plan import build_release_search_plan from shelfmark.metadata_providers import ( BookMetadata, get_provider, - is_provider_registered, get_provider_kwargs, + is_provider_registered, ) from shelfmark.release_sources import ( browse_record_to_book_metadata, @@ -2462,26 +2635,84 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: serialize_column_config, source_results_are_releases, ) - from shelfmark.core.search_plan import build_release_search_plan - provider = request.args.get('provider', '').strip() - book_id = request.args.get('book_id', '').strip() - source_filter = request.args.get('source', '').strip() - query_text = request.args.get('query', '').strip() - # Accept title/author from frontend to avoid re-fetching metadata - title_param = request.args.get('title', '').strip() - author_param = request.args.get('author', '').strip() - expand_search = request.args.get('expand_search', '').lower() == 'true' - # Accept language codes for filtering (comma-separated) - languages_param = request.args.get('languages', '').strip() - languages = [lang.strip() for lang in languages_param.split(',') if lang.strip()] if languages_param else None - # Content type for audiobook vs ebook search - content_type = request.args.get('content_type', 'ebook').strip() - manual_query = request.args.get('manual_query', '').strip() + def _search_source_releases(source_name: str) -> tuple[Any | None, list[Any], str | None]: + """Search one source and return any error message instead of raising.""" + try: + source = get_source(source_name) + + plan = build_release_search_plan( + book, + languages=browse_filters.lang + if source_query_filters is not None + else languages, + manual_query=query_text if source_query_filters is not None else manual_query, + indexers=indexers, + source_filters=source_query_filters, + ) + + if plan.source_filters is not None: + planned_query = plan.manual_query or plan.primary_query + planned_query_type = "query" + elif plan.manual_query: + planned_query = plan.manual_query + planned_query_type = "manual" + elif not expand_search and plan.isbn_candidates: + planned_query = plan.isbn_candidates[0] + planned_query_type = "isbn" + else: + planned_query = plan.primary_query + planned_query_type = "title_author" + + logger.debug( + "Searching %s: %s='%s' (title='%s', authors=%s, expand=%s, content_type=%s)", + source_name, + planned_query_type, + planned_query, + book.title, + book.authors, + expand_search, + content_type, + ) + + releases = source.search( + book, plan, expand_search=expand_search, content_type=content_type + ) + except ValueError: + return None, [], f"Unknown source: {source_name}" + except Exception as e: + logger.warning("Release search failed for source %s: %s", source_name, e) + return None, [], f"{source_name}: {e!s}" + else: + return source, releases, None + + provider = request.args.get("provider", "").strip() + book_id = request.args.get("book_id", "").strip() + source_filter = request.args.get("source", "").strip() + query_text = request.args.get("query", "").strip() + # Accept title/author from frontend to avoid re-fetching metadata + title_param = request.args.get("title", "").strip() + author_param = request.args.get("author", "").strip() + expand_search = request.args.get("expand_search", "").lower() == "true" + # Accept language codes for filtering (comma-separated) + languages_param = request.args.get("languages", "").strip() + languages = ( + [lang.strip() for lang in languages_param.split(",") if lang.strip()] + if languages_param + else None + ) + # Content type for audiobook vs ebook search + content_type = request.args.get("content_type", "ebook").strip() + + manual_query = request.args.get("manual_query", "").strip() # Accept indexer names for Prowlarr filtering (comma-separated) - indexers_param = request.args.get('indexers', '').strip() - indexers = [idx.strip() for idx in indexers_param.split(',') if idx.strip()] if indexers_param else None + indexers_param = request.args.get("indexers", "").strip() + indexers = ( + [idx.strip() for idx in indexers_param.split(",") if idx.strip()] + if indexers_param + else None + ) browse_filters = _parse_search_filters_from_request() has_browse_filters = bool(query_text or any(vars(browse_filters).values())) @@ -2492,7 +2723,9 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: if not source_filter or not has_browse_filters: return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400 if not source_results_are_releases(source_filter): - return jsonify({"error": f"Source does not support browse release search: {source_filter}"}), 400 + return jsonify( + {"error": f"Source does not support browse release search: {source_filter}"} + ), 400 book = _build_source_query_book(query_text, browse_filters) source_query_filters = browse_filters @@ -2544,9 +2777,7 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: book.title = title_param # Determine which release sources to search - if source_query_filters is not None: - sources_to_search = [source_filter] - elif source_filter: + if source_query_filters is not None or source_filter: sources_to_search = [source_filter] elif is_source_provider: # Source-backed browse flows stay within the source that produced the record. @@ -2561,43 +2792,12 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: source_instances = {} # Keep source instances for column config for source_name in sources_to_search: - try: - source = get_source(source_name) + source, releases, error = _search_source_releases(source_name) + if source is not None: source_instances[source_name] = source - - plan = build_release_search_plan( - book, - languages=browse_filters.lang if source_query_filters is not None else languages, - manual_query=query_text if source_query_filters is not None else manual_query, - indexers=indexers, - source_filters=source_query_filters, - ) - - if plan.source_filters is not None: - planned_query = plan.manual_query or plan.primary_query - planned_query_type = "query" - elif plan.manual_query: - planned_query = plan.manual_query - planned_query_type = "manual" - elif not expand_search and plan.isbn_candidates: - planned_query = plan.isbn_candidates[0] - planned_query_type = "isbn" - else: - planned_query = plan.primary_query - planned_query_type = "title_author" - - logger.debug( - f"Searching {source_name}: {planned_query_type}='{planned_query}' " - f"(title='{book.title}', authors={book.authors}, expand={expand_search}, content_type={content_type})" - ) - - releases = source.search(book, plan, expand_search=expand_search, content_type=content_type) all_releases.extend(releases) - except ValueError: - errors.append(f"Unknown source: {source_name}") - except Exception as e: - logger.warning(f"Release search failed for source {source_name}: {e}") - errors.append(f"{source_name}: {str(e)}") + if error is not None: + errors.append(error) # Convert Release objects to dicts releases_data = [_serialize_release(release) for release in all_releases] @@ -2610,21 +2810,20 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: first_source = source_instances[sources_to_search[0]] column_config = serialize_column_config(first_source.get_column_config()) except Exception as e: - logger.warning(f"Failed to get column config: {e}") + logger.warning("Failed to get column config: %s", e) # Convert book to dict and transform cover_url book_dict = asdict(book) from shelfmark.core.utils import transform_cover_url - if book_dict.get('cover_url'): + + if book_dict.get("cover_url"): cache_id = f"{provider}_{book_id}" - book_dict['cover_url'] = transform_cover_url(book_dict['cover_url'], cache_id) + book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id) search_info = {} for source_name, source_instance in source_instances.items(): - if hasattr(source_instance, 'last_search_type') and source_instance.last_search_type: - search_info[source_name] = { - "search_type": source_instance.last_search_type - } + if hasattr(source_instance, "last_search_type") and source_instance.last_search_type: + search_info[source_name] = {"search_type": source_instance.last_search_type} response = { "releases": releases_data, @@ -2650,24 +2849,25 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: return jsonify(response) except SourceUnavailableError as e: - logger.warning(f"Release search unavailable: {e}") + logger.warning("Release search unavailable: %s", e) return jsonify({"error": str(e)}), 503 except Exception as e: logger.error_trace(f"Releases search error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/release-sources', methods=['GET']) +@app.route("/api/release-sources", methods=["GET"]) @login_required -def api_release_sources() -> Union[Response, Tuple[Response, int]]: - """ - Get available release sources from the plugin registry. +def api_release_sources() -> Response | tuple[Response, int]: + """Get available release sources from the plugin registry. Returns: flask.Response: JSON list of available release sources. + """ try: from shelfmark.release_sources import list_available_sources + sources = list_available_sources() return jsonify(sources) except Exception as e: @@ -2675,9 +2875,9 @@ def api_release_sources() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 -@app.route('/api/release-sources//records/', methods=['GET']) +@app.route("/api/release-sources//records/", methods=["GET"]) @login_required -def api_release_source_record(source_name: str, record_id: str) -> Union[Response, Tuple[Response, int]]: +def api_release_source_record(source_name: str, record_id: str) -> Response | tuple[Response, int]: """Resolve a source-native browse record for a release source.""" try: from shelfmark.release_sources import get_source @@ -2690,31 +2890,31 @@ def api_release_source_record(source_name: str, record_id: str) -> Union[Respons except ValueError: return jsonify({"error": f"Unknown release source: {source_name}"}), 400 except SourceUnavailableError as e: - logger.warning(f"Release source record unavailable: {e}") + logger.warning("Release source record unavailable: %s", e) return jsonify({"error": str(e)}), 503 except Exception as e: logger.error_trace(f"Release source record error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/settings', methods=['GET']) +@app.route("/api/settings", methods=["GET"]) @login_required -def api_settings_get_all() -> Union[Response, Tuple[Response, int]]: - """ - Get all settings tabs with their fields and current values. +def api_settings_get_all() -> Response | tuple[Response, int]: + """Get all settings tabs with their fields and current values. Returns: flask.Response: JSON with all settings tabs. + """ try: - from shelfmark.core.settings_registry import serialize_all_settings + import_module("shelfmark.config.notifications_settings") + import_module("shelfmark.config.security") # Ensure settings are registered by importing settings modules # This triggers the @register_settings decorators - import shelfmark.config.settings # noqa: F401 - import shelfmark.config.security # noqa: F401 - import shelfmark.config.users_settings # noqa: F401 - import shelfmark.config.notifications_settings # noqa: F401 + import_module("shelfmark.config.settings") + import_module("shelfmark.config.users_settings") + from shelfmark.core.settings_registry import serialize_all_settings data = serialize_all_settings(include_values=True) return jsonify(data) @@ -2723,30 +2923,30 @@ def api_settings_get_all() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 -@app.route('/api/settings/', methods=['GET']) +@app.route("/api/settings/", methods=["GET"]) @login_required -def api_settings_get_tab(tab_name: str) -> Union[Response, Tuple[Response, int]]: - """ - Get settings for a specific tab. +def api_settings_get_tab(tab_name: str) -> Response | tuple[Response, int]: + """Get settings for a specific tab. Path Parameters: tab_name (str): Settings tab name (e.g., "general", "hardcover") Returns: flask.Response: JSON with tab settings and values. + """ try: + import_module("shelfmark.config.notifications_settings") + import_module("shelfmark.config.security") + + # Ensure settings are registered + import_module("shelfmark.config.settings") + import_module("shelfmark.config.users_settings") from shelfmark.core.settings_registry import ( get_settings_tab, serialize_tab, ) - # Ensure settings are registered - import shelfmark.config.settings # noqa: F401 - import shelfmark.config.security # noqa: F401 - import shelfmark.config.users_settings # noqa: F401 - import shelfmark.config.notifications_settings # noqa: F401 - tab = get_settings_tab(tab_name) if not tab: return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404 @@ -2757,11 +2957,10 @@ def api_settings_get_tab(tab_name: str) -> Union[Response, Tuple[Response, int]] return jsonify({"error": str(e)}), 500 -@app.route('/api/settings/', methods=['PUT']) +@app.route("/api/settings/", methods=["PUT"]) @login_required -def api_settings_update_tab(tab_name: str) -> Union[Response, Tuple[Response, int]]: - """ - Update settings for a specific tab. +def api_settings_update_tab(tab_name: str) -> Response | tuple[Response, int]: + """Update settings for a specific tab. Path Parameters: tab_name (str): Settings tab name @@ -2771,19 +2970,20 @@ def api_settings_update_tab(tab_name: str) -> Union[Response, Tuple[Response, in Returns: flask.Response: JSON with update result. + """ try: + import_module("shelfmark.config.notifications_settings") + import_module("shelfmark.config.security") + + # Ensure settings are registered + import_module("shelfmark.config.settings") + import_module("shelfmark.config.users_settings") from shelfmark.core.settings_registry import ( get_settings_tab, update_settings, ) - # Ensure settings are registered - import shelfmark.config.settings # noqa: F401 - import shelfmark.config.security # noqa: F401 - import shelfmark.config.users_settings # noqa: F401 - import shelfmark.config.notifications_settings # noqa: F401 - tab = get_settings_tab(tab_name) if not tab: return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404 @@ -2800,18 +3000,16 @@ def api_settings_update_tab(tab_name: str) -> Union[Response, Tuple[Response, in if result["success"]: return jsonify(result) - else: - return jsonify(result), 400 + return jsonify(result), 400 except Exception as e: logger.error_trace(f"Settings update error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/settings//action/', methods=['POST']) +@app.route("/api/settings//action/", methods=["POST"]) @login_required -def api_settings_execute_action(tab_name: str, action_key: str) -> Union[Response, Tuple[Response, int]]: - """ - Execute a settings action (e.g., test connection). +def api_settings_execute_action(tab_name: str, action_key: str) -> Response | tuple[Response, int]: + """Execute a settings action (e.g., test connection). Path Parameters: tab_name (str): Settings tab name @@ -2822,15 +3020,16 @@ def api_settings_execute_action(tab_name: str, action_key: str) -> Union[Respons Returns: flask.Response: JSON with action result. + """ try: - from shelfmark.core.settings_registry import execute_action + import_module("shelfmark.config.notifications_settings") + import_module("shelfmark.config.security") # Ensure settings are registered - import shelfmark.config.settings # noqa: F401 - import shelfmark.config.security # noqa: F401 - import shelfmark.config.users_settings # noqa: F401 - import shelfmark.config.notifications_settings # noqa: F401 + import_module("shelfmark.config.settings") + import_module("shelfmark.config.users_settings") + from shelfmark.core.settings_registry import execute_action # Get current form values if provided (for testing with unsaved values) current_values = request.get_json(silent=True) or {} @@ -2839,8 +3038,7 @@ def api_settings_execute_action(tab_name: str, action_key: str) -> Union[Respons if result["success"]: return jsonify(result) - else: - return jsonify(result), 400 + return jsonify(result), 400 except Exception as e: logger.error_trace(f"Settings action error: {e}") return jsonify({"error": str(e)}), 500 @@ -2851,20 +3049,19 @@ def api_settings_execute_action(tab_name: str, action_key: str) -> Union[Respons # ============================================================================= -@app.route('/api/onboarding', methods=['GET']) +@app.route("/api/onboarding", methods=["GET"]) @login_required -def api_onboarding_get() -> Union[Response, Tuple[Response, int]]: - """ - Get onboarding configuration including steps, fields, and current values. +def api_onboarding_get() -> Response | tuple[Response, int]: + """Get onboarding configuration including steps, fields, and current values. Returns: flask.Response: JSON with onboarding steps and values. + """ try: - from shelfmark.core.onboarding import get_onboarding_config - # Ensure settings are registered - import shelfmark.config.settings # noqa: F401 + import_module("shelfmark.config.settings") + from shelfmark.core.onboarding import get_onboarding_config config = get_onboarding_config() return jsonify(config) @@ -2873,23 +3070,22 @@ def api_onboarding_get() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 -@app.route('/api/onboarding', methods=['POST']) +@app.route("/api/onboarding", methods=["POST"]) @login_required -def api_onboarding_save() -> Union[Response, Tuple[Response, int]]: - """ - Save onboarding settings and mark as complete. +def api_onboarding_save() -> Response | tuple[Response, int]: + """Save onboarding settings and mark as complete. Request Body: JSON object with all onboarding field values Returns: flask.Response: JSON with success/error status. + """ try: - from shelfmark.core.onboarding import save_onboarding_settings - # Ensure settings are registered - import shelfmark.config.settings # noqa: F401 + import_module("shelfmark.config.settings") + from shelfmark.core.onboarding import save_onboarding_settings data = request.get_json() if not data: @@ -2899,21 +3095,20 @@ def api_onboarding_save() -> Union[Response, Tuple[Response, int]]: if result["success"]: return jsonify(result) - else: - return jsonify(result), 400 + return jsonify(result), 400 except Exception as e: logger.error_trace(f"Onboarding save error: {e}") return jsonify({"error": str(e)}), 500 -@app.route('/api/onboarding/skip', methods=['POST']) +@app.route("/api/onboarding/skip", methods=["POST"]) @login_required -def api_onboarding_skip() -> Union[Response, Tuple[Response, int]]: - """ - Skip onboarding and mark as complete without saving any settings. +def api_onboarding_skip() -> Response | tuple[Response, int]: + """Skip onboarding and mark as complete without saving any settings. Returns: flask.Response: JSON with success status. + """ try: from shelfmark.core.onboarding import mark_onboarding_complete @@ -2927,22 +3122,22 @@ def api_onboarding_skip() -> Union[Response, Tuple[Response, int]]: # Catch-all route for React Router (must be last) # This handles client-side routing by serving index.html for any unmatched routes -@app.route('/') +@app.route("/") def catch_all(path: str) -> Response: - """ - Serve the React app for any route not matched by API endpoints. + """Serve the React app for any route not matched by API endpoints. This allows React Router to handle client-side routing. Authentication is handled by the React app itself. """ # If the request is for an API endpoint or static file, let it 404 - if path.startswith('api/') or path.startswith('assets/'): + if path.startswith(("api/", "assets/")): return jsonify({"error": "Resource not found"}), 404 # Otherwise serve the React app return _serve_index_html() + # WebSocket event handlers -@socketio.on('connect') -def handle_connect(): +@socketio.on("connect") +def handle_connect() -> None: """Handle client connection.""" logger.info("WebSocket client connected") @@ -2956,17 +3151,18 @@ def handle_connect(): # Send initial status to the newly connected client (filtered) try: if not can_access_status: - emit('status_update', {}) + emit("status_update", {}) return user_id = None if is_admin else db_user_id status = backend.queue_status(user_id=user_id) - emit('status_update', status) - except Exception as e: - logger.error(f"Error sending initial status: {e}") + emit("status_update", status) + except Exception: + logger.exception("Error sending initial status") -@socketio.on('disconnect') -def handle_disconnect(): + +@socketio.on("disconnect") +def handle_disconnect() -> None: """Handle client disconnection.""" logger.info("WebSocket client disconnected") @@ -2976,39 +3172,46 @@ def handle_disconnect(): # Track the disconnection ws_manager.client_disconnected() -@socketio.on('request_status') -def handle_status_request(): + +@socketio.on("request_status") +def handle_status_request() -> None: """Handle manual status request from client.""" try: is_admin, db_user_id, can_access_status = _resolve_status_scope() ws_manager.sync_user_room(request.sid, is_admin, db_user_id) if not can_access_status: - emit('status_update', {}) + emit("status_update", {}) return user_id = None if is_admin else db_user_id status = backend.queue_status(user_id=user_id) - emit('status_update', status) - except Exception as e: - logger.error(f"Error handling status request: {e}") - emit('error', {'message': 'Failed to get status'}) + emit("status_update", status) + except Exception: + logger.exception("Error handling status request") + emit("error", {"message": "Failed to get status"}) + logger.log_resource_usage() # Warn if config directory is not writable (settings won't persist) if not _is_config_dir_writable(): logger.warning( - f"Config directory {CONFIG_DIR} is not writable. Settings will not persist. " - "Mount a config volume to enable settings persistence (see docs for details)." + "Config directory %s is not writable. Settings will not persist. Mount a config volume to enable settings persistence (see docs for details).", + CONFIG_DIR, ) -if __name__ == '__main__': - logger.info(f"Starting Flask application with WebSocket support on {FLASK_HOST}:{FLASK_PORT} (debug={DEBUG})") +if __name__ == "__main__": + logger.info( + "Starting Flask application with WebSocket support on %s:%s (debug=%s)", + FLASK_HOST, + FLASK_PORT, + DEBUG, + ) socketio.run( app, host=FLASK_HOST, port=FLASK_PORT, debug=DEBUG, - allow_unsafe_werkzeug=True # For development only + allow_unsafe_werkzeug=True, # For development only ) diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index a6512790..3de5ec6a 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -1,31 +1,37 @@ """Metadata provider plugin system - base classes and registry.""" from abc import ABC, abstractmethod +from contextlib import suppress from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Dict, List, Optional, Type, Union +from enum import StrEnum +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from collections.abc import Callable -class SearchType(str, Enum): +class SearchType(StrEnum): """Type of search to perform.""" + GENERAL = "general" # Search all fields (title, author, ISBN, etc.) - TITLE = "title" # Search by title only - AUTHOR = "author" # Search by author only - ISBN = "isbn" # Search by ISBN + TITLE = "title" # Search by title only + AUTHOR = "author" # Search by author only + ISBN = "isbn" # Search by ISBN -class SortOrder(str, Enum): +class SortOrder(StrEnum): """Sort order for search results.""" - RELEVANCE = "relevance" # Best match first (default) + + RELEVANCE = "relevance" # Best match first (default) POPULARITY = "popularity" # Most popular first - RATING = "rating" # Highest rated first - NEWEST = "newest" # Most recently published first - OLDEST = "oldest" # Oldest published first + RATING = "rating" # Highest rated first + NEWEST = "newest" # Most recently published first + OLDEST = "oldest" # Oldest published first SERIES_ORDER = "series_order" # By series position (requires series field) # Display labels for sort options -SORT_LABELS: Dict[SortOrder, str] = { +SORT_LABELS: dict[SortOrder, str] = { SortOrder.RELEVANCE: "Most relevant", SortOrder.POPULARITY: "Most popular", SortOrder.RATING: "Highest rated", @@ -38,40 +44,44 @@ SORT_LABELS: Dict[SortOrder, str] = { @dataclass class MetadataCapability: """Declarative provider capability consumed by shared UI code.""" + key: str - field_key: Optional[str] = None - sort: Optional[SortOrder] = None + field_key: str | None = None + sort: SortOrder | None = None @dataclass class TextSearchField: """Text input search field.""" - key: str # Field identifier (e.g., "author", "publisher") - label: str # Display label in UI - placeholder: str = "" # Placeholder text - description: str = "" # Help text - suggestions_endpoint: Optional[str] = None # Remote suggestions endpoint for typeahead - suggestions_min_query_length: int = 2 # Minimum chars before requesting suggestions + + key: str # Field identifier (e.g., "author", "publisher") + label: str # Display label in UI + placeholder: str = "" # Placeholder text + description: str = "" # Help text + suggestions_endpoint: str | None = None # Remote suggestions endpoint for typeahead + suggestions_min_query_length: int = 2 # Minimum chars before requesting suggestions @dataclass class NumberSearchField: """Numeric input search field.""" + key: str label: str placeholder: str = "" description: str = "" - min_value: Optional[int] = None - max_value: Optional[int] = None + min_value: int | None = None + max_value: int | None = None step: int = 1 @dataclass class SelectSearchField: """Single-choice dropdown search field.""" + key: str label: str - options: List[Dict[str, str]] = field(default_factory=list) # [{value: "", label: ""}] + options: list[dict[str, str]] = field(default_factory=list) # [{value: "", label: ""}] placeholder: str = "" description: str = "" @@ -79,6 +89,7 @@ class SelectSearchField: @dataclass class CheckboxSearchField: """Boolean checkbox search field.""" + key: str label: str description: str = "" @@ -88,6 +99,7 @@ class CheckboxSearchField: @dataclass class DynamicSelectSearchField: """Single-choice dropdown field with options loaded from an API endpoint.""" + key: str label: str options_endpoint: str @@ -96,18 +108,18 @@ class DynamicSelectSearchField: # Type alias for all search field types -SearchField = Union[ - TextSearchField, - NumberSearchField, - SelectSearchField, - CheckboxSearchField, - DynamicSelectSearchField, -] +SearchField = ( + TextSearchField + | NumberSearchField + | SelectSearchField + | CheckboxSearchField + | DynamicSelectSearchField +) -def serialize_metadata_capability(capability: MetadataCapability) -> Dict[str, Any]: +def serialize_metadata_capability(capability: MetadataCapability) -> dict[str, Any]: """Serialize a provider capability for API responses.""" - result: Dict[str, Any] = { + result: dict[str, Any] = { "key": capability.key, } @@ -120,14 +132,14 @@ def serialize_metadata_capability(capability: MetadataCapability) -> Dict[str, A return result -def serialize_search_field(search_field: SearchField) -> Dict[str, Any]: +def serialize_search_field(search_field: SearchField) -> dict[str, Any]: """Serialize a search field to dict for API response.""" - result: Dict[str, Any] = { + result: dict[str, Any] = { "key": search_field.key, "label": search_field.label, "type": search_field.__class__.__name__, - "placeholder": getattr(search_field, 'placeholder', ''), - "description": getattr(search_field, 'description', ''), + "placeholder": getattr(search_field, "placeholder", ""), + "description": getattr(search_field, "description", ""), } # Add type-specific properties @@ -152,70 +164,73 @@ def serialize_search_field(search_field: SearchField) -> Dict[str, Any]: @dataclass class MetadataSearchOptions: """Options for metadata search queries across all providers.""" + query: str search_type: SearchType = SearchType.GENERAL - language: Optional[str] = None # ISO 639-1 code (e.g., "en", "fr") + language: str | None = None # ISO 639-1 code (e.g., "en", "fr") sort: SortOrder = SortOrder.RELEVANCE limit: int = 40 page: int = 1 - fields: Dict[str, Any] = field(default_factory=dict) # Custom search field values + fields: dict[str, Any] = field(default_factory=dict) # Custom search field values @dataclass class DisplayField: """A display field for metadata cards (ratings, page counts, etc.).""" - label: str # e.g., "Rating", "Pages", "Readers" - value: str # e.g., "4.5", "496", "8,041" - icon: Optional[str] = None # Icon name: "star", "book", "users", "editions" + + label: str # e.g., "Rating", "Pages", "Readers" + value: str # e.g., "4.5", "496", "8,041" + icon: str | None = None # Icon name: "star", "book", "users", "editions" @dataclass class BookMetadata: """Book from metadata provider (not a specific release).""" - provider: str # Which provider this came from (internal name) - provider_id: str # ID in that provider's system + + provider: str # Which provider this came from (internal name) + provider_id: str # ID in that provider's system title: str # Provider display name for UI (e.g., "Open Library" instead of "openlibrary") - provider_display_name: Optional[str] = None + provider_display_name: str | None = None # Optional - not all providers have all fields - authors: List[str] = field(default_factory=list) - isbn_10: Optional[str] = None - isbn_13: Optional[str] = None - cover_url: Optional[str] = None - description: Optional[str] = None - publisher: Optional[str] = None - publish_year: Optional[int] = None - language: Optional[str] = None - genres: List[str] = field(default_factory=list) - source_url: Optional[str] = None # Link to book on provider's site - subtitle: Optional[str] = None # Book subtitle, if any - search_title: Optional[str] = None # Cleaner title for search queries (provider-specific) - search_author: Optional[str] = None # Cleaner author for search queries (provider-specific) + authors: list[str] = field(default_factory=list) + isbn_10: str | None = None + isbn_13: str | None = None + cover_url: str | None = None + description: str | None = None + publisher: str | None = None + publish_year: int | None = None + language: str | None = None + genres: list[str] = field(default_factory=list) + source_url: str | None = None # Link to book on provider's site + subtitle: str | None = None # Book subtitle, if any + search_title: str | None = None # Cleaner title for search queries (provider-specific) + search_author: str | None = None # Cleaner author for search queries (provider-specific) # Cover aspect ratio hint for the frontend ("portrait" or "square") - cover_aspect: Optional[str] = None + cover_aspect: str | None = None # Provider-specific display fields for cards/lists - display_fields: List[DisplayField] = field(default_factory=list) + display_fields: list[DisplayField] = field(default_factory=list) # Series info (if book is part of a series) - series_id: Optional[str] = None # Provider-specific series ID - series_name: Optional[str] = None # Name of the series - series_position: Optional[float] = None # This book's position (e.g., 3, 1.5 for novellas) - series_count: Optional[int] = None # Total books in the series + series_id: str | None = None # Provider-specific series ID + series_name: str | None = None # Name of the series + series_position: float | None = None # This book's position (e.g., 3, 1.5 for novellas) + series_count: int | None = None # Total books in the series # Alternative titles by language (for localized searches) # Maps language code (e.g., "de", "German") to localized title - titles_by_language: Dict[str, str] = field(default_factory=dict) + titles_by_language: dict[str, str] = field(default_factory=dict) def group_languages_by_localized_title( base_title: str, - languages: Optional[List[str]], - titles_by_language: Optional[Dict[str, str]] = None, -) -> List[tuple[str, Optional[List[str]]]]: + languages: list[str] | None, + titles_by_language: dict[str, str] | None = None, +) -> list[tuple[str, list[str] | None]]: """Group language codes by localized title. Release sources that support language filtering (e.g., Anna's Archive) @@ -230,6 +245,7 @@ def group_languages_by_localized_title( Returns: List of (title, languages) tuples. If languages is None/empty, returns [(base_title, None)]. + """ if not base_title: return [] @@ -244,7 +260,7 @@ def group_languages_by_localized_title( if not titles_by_language: return [(base_title, normalized_langs)] - title_to_langs: Dict[str, List[str]] = {} + title_to_langs: dict[str, list[str]] = {} for lang in normalized_langs: localized_title = titles_by_language.get(lang) or base_title title_to_langs.setdefault(localized_title, []).append(lang) @@ -254,10 +270,10 @@ def group_languages_by_localized_title( def build_localized_search_titles( base_title: str, - languages: Optional[List[str]], - titles_by_language: Optional[Dict[str, str]] = None, - excluded_languages: Optional[set[str]] = None, -) -> List[str]: + languages: list[str] | None, + titles_by_language: dict[str, str] | None = None, + excluded_languages: set[str] | None = None, +) -> list[str]: """Build a list of titles to search for, including localized editions. This is useful for release sources that *can't* pass language filters to @@ -274,11 +290,12 @@ def build_localized_search_titles( Returns: List of unique titles to search for, in priority order. + """ if not base_title: return [] - titles: List[str] = [base_title] + titles: list[str] = [base_title] seen = {base_title} if not languages or not titles_by_language: @@ -309,12 +326,13 @@ def build_localized_search_titles( @dataclass class SearchResult: """Result from a metadata search with pagination info.""" - books: List[BookMetadata] + + books: list[BookMetadata] page: int = 1 total_found: int = 0 # Total matching results (if known) has_more: bool = False # True if more results available - source_url: Optional[str] = None # External URL for the result set (e.g. Hardcover list page) - source_title: Optional[str] = None # Display title for the result set (e.g. list name) + source_url: str | None = None # External URL for the result set (e.g. Hardcover list page) + source_title: str | None = None # Display title for the result set (e.g. list name) class MetadataProvider(ABC): @@ -330,33 +348,31 @@ class MetadataProvider(ABC): supported_sorts: List of SortOrder values this provider supports search_fields: List of provider-specific search fields capabilities: Declarative capabilities exposed to shared UI code + """ + name: str display_name: str requires_auth: bool - supported_sorts: List[SortOrder] = [SortOrder.RELEVANCE] - search_fields: List[SearchField] = [] - capabilities: List[MetadataCapability] = [] + supported_sorts: ClassVar[tuple[SortOrder, ...]] = (SortOrder.RELEVANCE,) + search_fields: ClassVar[tuple[SearchField, ...]] = () + capabilities: ClassVar[tuple[MetadataCapability, ...]] = () @abstractmethod - def search(self, options: MetadataSearchOptions) -> List[BookMetadata]: + def search(self, options: MetadataSearchOptions) -> list[BookMetadata]: """Search for books using the provided options.""" - pass @abstractmethod - def get_book(self, book_id: str) -> Optional[BookMetadata]: + def get_book(self, book_id: str) -> BookMetadata | None: """Get a specific book by provider ID.""" - pass @abstractmethod - def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]: + def search_by_isbn(self, isbn: str) -> BookMetadata | None: """Search for a book by ISBN.""" - pass @abstractmethod def is_available(self) -> bool: """Check if this provider is configured and available.""" - pass def search_paginated(self, options: MetadataSearchOptions) -> SearchResult: """Search with pagination info. Override for accurate pagination.""" @@ -367,62 +383,71 @@ class MetadataProvider(ABC): books=books, page=options.page, total_found=0, # Unknown without provider-specific implementation - has_more=has_more + has_more=has_more, ) def get_search_field_options( self, field_key: str, - query: Optional[str] = None, - ) -> List[Dict[str, str]]: + query: str | None = None, + ) -> list[dict[str, str]]: """Get dynamic options for a provider-specific search field.""" return [] - def get_book_targets(self, book_id: str) -> List[Dict[str, Any]]: + def get_book_targets(self, book_id: str) -> list[dict[str, Any]]: """Get provider-managed list or status targets for a specific book.""" - raise NotImplementedError(f"{self.display_name} does not support book targets") + msg = f"{self.display_name} does not support book targets" + raise NotImplementedError(msg) - def get_book_targets_batch(self, book_ids: List[str]) -> Dict[str, List[Dict[str, Any]]]: + def get_book_targets_batch(self, book_ids: list[str]) -> dict[str, list[dict[str, Any]]]: """Get provider-managed targets for multiple books. Returns a dict mapping each book_id to its list of target options. Default implementation calls get_book_targets per book. """ - results: Dict[str, List[Dict[str, Any]]] = {} - for book_id in book_ids: - try: - results[book_id] = self.get_book_targets(book_id) - except (NotImplementedError, ValueError): - results[book_id] = [] - return results + return {book_id: self._get_book_targets_for_batch(book_id) for book_id in book_ids} + + def _get_book_targets_for_batch(self, book_id: str) -> list[dict[str, Any]]: + """Safely fetch targets for one book, falling back to an empty list.""" + try: + return self.get_book_targets(book_id) + except NotImplementedError, ValueError: + return [] def set_book_target_state( self, book_id: str, target: str, selected: bool, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Set whether a book belongs to a provider-managed list or shelf. Returns a dict with at least ``{"changed": bool}``. """ - raise NotImplementedError(f"{self.display_name} does not support book targets") + msg = f"{self.display_name} does not support book targets" + raise NotImplementedError(msg) # Provider registry -_PROVIDERS: Dict[str, Type[MetadataProvider]] = {} -_PROVIDER_KWARGS_FACTORIES: Dict[str, Any] = {} # Callable[[], Dict] +_PROVIDERS: dict[str, type[MetadataProvider]] = {} +_PROVIDER_KWARGS_FACTORIES: dict[str, Any] = {} # Callable[[], Dict] -def register_provider(name: str): +def register_provider( + name: str, +) -> Callable[[type[MetadataProvider]], type[MetadataProvider]]: """Decorator to register a metadata provider.""" - def decorator(cls): + + def decorator(cls: type[MetadataProvider]) -> type[MetadataProvider]: _PROVIDERS[name] = cls return cls + return decorator -def register_provider_kwargs(name: str): +def register_provider_kwargs( + name: str, +) -> Callable[[Callable[[], dict[str, Any]]], Callable[[], dict[str, Any]]]: """Decorator to register a provider's kwargs factory. The decorated function should return a Dict of kwargs to pass to the @@ -434,21 +459,25 @@ def register_provider_kwargs(name: str): def _hardcover_kwargs() -> Dict: from shelfmark.core.config import config return {"api_key": config.get("HARDCOVER_API_KEY", "")} + """ - def decorator(fn): + + def decorator(fn: Callable[[], dict[str, Any]]) -> Callable[[], dict[str, Any]]: _PROVIDER_KWARGS_FACTORIES[name] = fn return fn + return decorator def get_provider(name: str, **kwargs) -> MetadataProvider: """Factory - instantiate any registered provider.""" if name not in _PROVIDERS: - raise ValueError(f"Unknown metadata provider: {name}") + msg = f"Unknown metadata provider: {name}" + raise ValueError(msg) return _PROVIDERS[name](**kwargs) -def list_providers() -> List[dict]: +def list_providers() -> list[dict]: """For settings UI - list available providers with their requirements.""" return [ {"name": n, "display_name": c.display_name, "requires_auth": c.requires_auth} @@ -456,7 +485,7 @@ def list_providers() -> List[dict]: ] -def get_provider_kwargs(provider_name: str) -> Dict: +def get_provider_kwargs(provider_name: str) -> dict: """Get provider-specific initialization kwargs from registered factory.""" factory = _PROVIDER_KWARGS_FACTORIES.get(provider_name) if factory: @@ -481,15 +510,15 @@ def is_provider_enabled(provider_name: str) -> bool: return app_config.get(enabled_key, False) is True -def get_enabled_providers() -> List[str]: +def get_enabled_providers() -> list[str]: """Get list of all enabled provider names.""" return [name for name in _PROVIDERS if is_provider_enabled(name)] def get_configured_provider( content_type: str = "ebook", - user_id: Optional[int] = None, -) -> Optional[MetadataProvider]: + user_id: int | None = None, +) -> MetadataProvider | None: """Get the currently configured metadata provider for the content type.""" from shelfmark.core.config import config as app_config @@ -520,7 +549,8 @@ def get_configured_provider( def get_configured_provider_name( content_type: str = "ebook", - user_id: Optional[int] = None, + user_id: int | None = None, + *, fallback_to_main: bool = True, ) -> str: """Get the configured metadata provider name for a content type.""" @@ -550,16 +580,16 @@ def get_configured_provider_name( def get_provider_sort_options( - provider_name: Optional[str] = None, - user_id: Optional[int] = None, -) -> List[Dict[str, str]]: + provider_name: str | None = None, + user_id: int | None = None, +) -> list[dict[str, str]]: """Get sort options for a metadata provider as {value, label} dicts.""" if provider_name is None: provider_name = get_configured_provider_name(user_id=user_id) if provider_name and provider_name in _PROVIDERS: provider_class = _PROVIDERS[provider_name] - supported = getattr(provider_class, 'supported_sorts', [SortOrder.RELEVANCE]) + supported = getattr(provider_class, "supported_sorts", [SortOrder.RELEVANCE]) else: supported = [SortOrder.RELEVANCE] @@ -570,16 +600,16 @@ def get_provider_sort_options( def get_provider_search_fields( - provider_name: Optional[str] = None, - user_id: Optional[int] = None, -) -> List[Dict[str, Any]]: + provider_name: str | None = None, + user_id: int | None = None, +) -> list[dict[str, Any]]: """Get search fields for a metadata provider as serialized dicts.""" if provider_name is None: provider_name = get_configured_provider_name(user_id=user_id) if provider_name and provider_name in _PROVIDERS: provider_class = _PROVIDERS[provider_name] - fields = getattr(provider_class, 'search_fields', []) + fields = getattr(provider_class, "search_fields", []) else: fields = [] @@ -587,9 +617,9 @@ def get_provider_search_fields( def get_provider_capabilities( - provider_name: Optional[str] = None, - user_id: Optional[int] = None, -) -> List[Dict[str, Any]]: + provider_name: str | None = None, + user_id: int | None = None, +) -> list[dict[str, Any]]: """Get declarative capabilities for a metadata provider.""" if provider_name is None: provider_name = get_configured_provider_name(user_id=user_id) @@ -604,8 +634,8 @@ def get_provider_capabilities( def get_provider_default_sort( - provider_name: Optional[str] = None, - user_id: Optional[int] = None, + provider_name: str | None = None, + user_id: int | None = None, ) -> str: """Get the default sort order for a metadata provider.""" from shelfmark.core.config import config as app_config @@ -629,7 +659,7 @@ def sync_metadata_provider_selection() -> None: enabling/disabling a provider. """ from shelfmark.core.config import config as app_config - from shelfmark.core.settings_registry import save_config_file, load_config_file + from shelfmark.core.settings_registry import load_config_file, save_config_file app_config.refresh() @@ -653,18 +683,11 @@ def sync_metadata_provider_selection() -> None: # Import provider implementations to trigger registration # These must be imported AFTER the base classes and registry are defined -try: - from shelfmark.metadata_providers import hardcover # noqa: F401, E402 -except ImportError: - pass # Hardcover provider is optional +with suppress(ImportError): + from shelfmark.metadata_providers import hardcover as hardcover -try: - from shelfmark.metadata_providers import openlibrary # noqa: F401, E402 -except ImportError: - pass # Open Library provider is optional - -try: - from shelfmark.metadata_providers import googlebooks # noqa: F401, E402 -except ImportError: - pass # Google Books provider is optional +with suppress(ImportError): + from shelfmark.metadata_providers import openlibrary as openlibrary +with suppress(ImportError): + from shelfmark.metadata_providers import googlebooks as googlebooks diff --git a/shelfmark/metadata_providers/googlebooks.py b/shelfmark/metadata_providers/googlebooks.py index aed30410..2049f7ac 100644 --- a/shelfmark/metadata_providers/googlebooks.py +++ b/shelfmark/metadata_providers/googlebooks.py @@ -6,20 +6,24 @@ Requires a free API key from Google Cloud Console (~1000 requests/day quota). API Documentation: https://developers.google.com/books/docs/v1/using """ +from contextlib import suppress +from http import HTTPStatus +from typing import Any, ClassVar + import requests -from typing import Any, Dict, List, Optional from shelfmark.core.cache import cacheable +from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger from shelfmark.core.settings_registry import ( - register_settings, + ActionButton, CheckboxField, + HeadingField, PasswordField, SelectField, - ActionButton, - HeadingField, + SettingsField, + register_settings, ) -from shelfmark.core.config import config as app_config from shelfmark.download.network import get_ssl_verify from shelfmark.metadata_providers import ( BookMetadata, @@ -28,18 +32,21 @@ from shelfmark.metadata_providers import ( MetadataSearchOptions, SearchType, SortOrder, + TextSearchField, register_provider, register_provider_kwargs, - TextSearchField, ) - logger = setup_logger(__name__) +_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN +_HTTP_STATUS_BAD_REQUEST = HTTPStatus.BAD_REQUEST +_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND + GOOGLE_BOOKS_BASE_URL = "https://www.googleapis.com/books/v1" # Sort mapping - Google only supports "relevance" and "newest" -SORT_MAPPING: Dict[SortOrder, Optional[str]] = { +SORT_MAPPING: dict[SortOrder, str | None] = { SortOrder.RELEVANCE: None, # Default, no param needed SortOrder.NEWEST: "newest", # POPULARITY, RATING, OLDEST not supported - fall back to relevance @@ -47,7 +54,7 @@ SORT_MAPPING: Dict[SortOrder, Optional[str]] = { @register_provider_kwargs("googlebooks") -def _googlebooks_kwargs() -> Dict[str, Any]: +def _googlebooks_kwargs() -> dict[str, Any]: """Provide Google Books-specific constructor kwargs.""" return {"api_key": app_config.get("GOOGLEBOOKS_API_KEY", "")} @@ -59,8 +66,11 @@ class GoogleBooksProvider(MetadataProvider): name = "googlebooks" display_name = "Google Books" requires_auth = True - supported_sorts = [SortOrder.RELEVANCE, SortOrder.NEWEST] - search_fields = [ + supported_sorts: ClassVar[tuple[SortOrder, ...]] = ( + SortOrder.RELEVANCE, + SortOrder.NEWEST, + ) + search_fields: ClassVar[tuple[TextSearchField, ...]] = ( TextSearchField( key="author", label="Author", @@ -71,9 +81,9 @@ class GoogleBooksProvider(MetadataProvider): label="Title", description="Search by book title", ), - ] + ) - def __init__(self, api_key: Optional[str] = None): + def __init__(self, api_key: str | None = None) -> None: """Initialize provider with optional API key (falls back to config).""" self.api_key = api_key or app_config.get("GOOGLEBOOKS_API_KEY", "") self.session = requests.Session() @@ -82,7 +92,7 @@ class GoogleBooksProvider(MetadataProvider): """Check if provider is configured with an API key.""" return bool(self.api_key) - def search(self, options: MetadataSearchOptions) -> List[BookMetadata]: + def search(self, options: MetadataSearchOptions) -> list[BookMetadata]: """Search for books using Google Books API.""" if not self.api_key: logger.warning("Google Books API key not configured") @@ -106,9 +116,7 @@ class GoogleBooksProvider(MetadataProvider): ttl_default=300, key_prefix="googlebooks:search", ) - def _search_cached( - self, cache_key: str, options: MetadataSearchOptions - ) -> List[BookMetadata]: + def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> list[BookMetadata]: """Cached search implementation.""" # Build query string with Google Books operators author_value = options.fields.get("author", "").strip() @@ -134,7 +142,7 @@ class GoogleBooksProvider(MetadataProvider): query = "+".join(query_parts) # Build request params - params: Dict[str, Any] = { + params: dict[str, Any] = { "q": query, "maxResults": min(options.limit, 40), # Google max is 40 "startIndex": (options.page - 1) * options.limit, @@ -150,32 +158,30 @@ class GoogleBooksProvider(MetadataProvider): if options.language: params["langRestrict"] = options.language + books: list[BookMetadata] = [] try: result = self._make_request("/volumes", params) - if not result: - return [] + if result: + items = result.get("items", []) - items = result.get("items", []) - books = [] + for item in items: + book = self._parse_volume(item) + if book: + books.append(book) - for item in items: - book = self._parse_volume(item) - if book: - books.append(book) + logger.info("Google Books search '%s' returned %s results", query, len(books)) - logger.info(f"Google Books search '{query}' returned {len(books)} results") - return books - - except Exception as e: - logger.error(f"Google Books search error: {e}") + except Exception: + logger.exception("Google Books search error") return [] + return books @cacheable( ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="googlebooks:book", ) - def get_book(self, book_id: str) -> Optional[BookMetadata]: + def get_book(self, book_id: str) -> BookMetadata | None: """Get book details by Google Books volume ID.""" try: result = self._make_request(f"/volumes/{book_id}", {}) @@ -184,8 +190,8 @@ class GoogleBooksProvider(MetadataProvider): return self._parse_volume(result) - except Exception as e: - logger.error(f"Google Books get_book error: {e}") + except Exception: + logger.exception("Google Books get_book error") return None @cacheable( @@ -193,13 +199,13 @@ class GoogleBooksProvider(MetadataProvider): ttl_default=600, key_prefix="googlebooks:isbn", ) - def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]: + def search_by_isbn(self, isbn: str) -> BookMetadata | None: """Search for a book by ISBN-10 or ISBN-13.""" # Clean ISBN (remove hyphens and spaces) clean_isbn = isbn.replace("-", "").replace(" ", "").strip() # Use ISBN operator for precise lookup - params: Dict[str, Any] = { + params: dict[str, Any] = { "q": f"isbn:{clean_isbn}", "maxResults": 1, } @@ -211,18 +217,16 @@ class GoogleBooksProvider(MetadataProvider): items = result.get("items", []) if not items: - logger.debug(f"No Google Books result for ISBN: {isbn}") + logger.debug("No Google Books result for ISBN: %s", isbn) return None return self._parse_volume(items[0]) - except Exception as e: - logger.error(f"Google Books ISBN search error: {e}") + except Exception: + logger.exception("Google Books ISBN search error") return None - def _make_request( - self, endpoint: str, params: Dict[str, Any] - ) -> Optional[Dict[str, Any]]: + def _make_request(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any] | None: """Make authenticated API request to endpoint.""" if not self.api_key: logger.warning("Google Books API key not configured") @@ -243,25 +247,25 @@ class GoogleBooksProvider(MetadataProvider): return None except requests.HTTPError as e: if e.response is not None: - if e.response.status_code == 403: + if e.response.status_code == _HTTP_STATUS_FORBIDDEN: # Quota exceeded or invalid API key - logger.error( + logger.exception( "Google Books API: quota exceeded or invalid API key (HTTP 403)" ) - elif e.response.status_code == 400: - logger.warning(f"Google Books API: bad request - {e}") - elif e.response.status_code == 404: + elif e.response.status_code == _HTTP_STATUS_BAD_REQUEST: + logger.warning("Google Books API: bad request - %s", e) + elif e.response.status_code == _HTTP_STATUS_NOT_FOUND: logger.debug("Google Books: volume not found") else: - logger.error(f"Google Books API HTTP error: {e}") + logger.exception("Google Books API HTTP error") else: - logger.error(f"Google Books API HTTP error: {e}") + logger.exception("Google Books API HTTP error") return None - except Exception as e: - logger.error(f"Google Books API request failed: {e}") + except Exception: + logger.exception("Google Books API request failed") return None - def _parse_volume(self, volume: Dict[str, Any]) -> Optional[BookMetadata]: + def _parse_volume(self, volume: dict[str, Any]) -> BookMetadata | None: """Parse a volume object into BookMetadata.""" try: volume_id = volume.get("id") @@ -296,9 +300,7 @@ class GoogleBooksProvider(MetadataProvider): ) # Remove edge=curl parameter and upgrade to https if cover_url: - cover_url = cover_url.replace("&edge=curl", "").replace( - "http://", "https://" - ) + cover_url = cover_url.replace("&edge=curl", "").replace("http://", "https://") # Publisher publisher = volume_info.get("publisher") @@ -307,10 +309,8 @@ class GoogleBooksProvider(MetadataProvider): publish_year = None published_date = volume_info.get("publishedDate", "") if published_date: - try: + with suppress(ValueError, TypeError): publish_year = int(published_date[:4]) - except (ValueError, TypeError): - pass # Language language = volume_info.get("language") @@ -325,7 +325,7 @@ class GoogleBooksProvider(MetadataProvider): source_url = volume_info.get("infoLink") # Build display fields - rating only - display_fields: List[DisplayField] = [] + display_fields: list[DisplayField] = [] average_rating = volume_info.get("averageRating") ratings_count = volume_info.get("ratingsCount") @@ -333,9 +333,7 @@ class GoogleBooksProvider(MetadataProvider): rating_str = f"{average_rating:.1f}" if ratings_count: rating_str += f" ({ratings_count:,})" - display_fields.append( - DisplayField(label="Rating", value=rating_str, icon="star") - ) + display_fields.append(DisplayField(label="Rating", value=rating_str, icon="star")) return BookMetadata( provider="googlebooks", @@ -356,11 +354,13 @@ class GoogleBooksProvider(MetadataProvider): ) except Exception as e: - logger.debug(f"Failed to parse Google Books volume: {e}") + logger.debug("Failed to parse Google Books volume: %s", e) return None -def _test_googlebooks_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: +def _test_googlebooks_connection( + current_values: dict[str, Any] | None = None, +) -> dict[str, Any]: """Test the Google Books API connection using current form values.""" current_values = current_values or {} @@ -377,25 +377,25 @@ def _test_googlebooks_connection(current_values: Dict[str, Any] = None) -> Dict[ provider = GoogleBooksProvider(api_key=api_key) # Simple test search result = provider._make_request("/volumes", {"q": "test", "maxResults": 1}) + test_result = { + "success": False, + "message": "API request failed - check your API key", + } if result is not None and "items" in result: - return { + test_result = { "success": True, "message": "Successfully connected to Google Books API", } elif result is not None: - return { + test_result = { "success": True, "message": "API connected but returned no results for test query", } - else: - return { - "success": False, - "message": "API request failed - check your API key", - } except Exception as e: logger.exception("Google Books connection test failed") - return {"success": False, "message": f"Connection failed: {str(e)}"} + return {"success": False, "message": f"Connection failed: {e!s}"} + return test_result # Sort options for settings UI @@ -405,10 +405,8 @@ _GOOGLEBOOKS_SORT_OPTIONS = [ ] -@register_settings( - "googlebooks", "Google Books", icon="book", order=53, group="metadata_providers" -) -def googlebooks_settings(): +@register_settings("googlebooks", "Google Books", icon="book", order=53, group="metadata_providers") +def googlebooks_settings() -> list[SettingsField]: """Google Books metadata provider settings.""" return [ HeadingField( @@ -431,8 +429,7 @@ def googlebooks_settings(): key="GOOGLEBOOKS_API_KEY", label="API Key", description=( - "Get your API key from Google Cloud Console " - "(APIs & Services > Credentials)" + "Get your API key from Google Cloud Console (APIs & Services > Credentials)" ), required=True, ), diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 5ada37ff..f8d1abf1 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -1,38 +1,41 @@ """Hardcover.app metadata provider. Requires API key.""" import re -import requests +from contextlib import suppress from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar from urllib.parse import urlparse +import requests + from shelfmark.core.cache import cache_key, cacheable, get_metadata_cache +from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger +from shelfmark.core.request_helpers import coerce_int from shelfmark.core.settings_registry import ( - register_settings, + ActionButton, CheckboxField, + HeadingField, PasswordField, SelectField, - ActionButton, - HeadingField, + SettingsField, + register_settings, ) -from shelfmark.core.config import config as app_config -from shelfmark.core.request_helpers import coerce_int from shelfmark.download.network import get_ssl_verify from shelfmark.metadata_providers import ( BookMetadata, DisplayField, + DynamicSelectSearchField, MetadataCapability, MetadataProvider, MetadataSearchOptions, SearchResult, SearchType, SortOrder, + TextSearchField, register_provider, register_provider_kwargs, - DynamicSelectSearchField, - TextSearchField, ) logger = setup_logger(__name__) @@ -389,9 +392,19 @@ query GetSeriesBooks($seriesId: Int!) { HARDCOVER_STATUS_PREFIX = "status:" HARDCOVER_STATUSES: list[dict] = [ {"id": 1, "label": "Want to Read", "slug": "want-to-read", "query_key": "want_to_read_count"}, - {"id": 2, "label": "Currently Reading", "slug": "currently-reading", "query_key": "currently_reading_count"}, + { + "id": 2, + "label": "Currently Reading", + "slug": "currently-reading", + "query_key": "currently_reading_count", + }, {"id": 3, "label": "Read", "slug": "read", "query_key": "read_count"}, - {"id": 5, "label": "Did Not Finish", "slug": "did-not-finish", "query_key": "did_not_finish_count"}, + { + "id": 5, + "label": "Did Not Finish", + "slug": "did-not-finish", + "query_key": "did_not_finish_count", + }, ] HARDCOVER_STATUS_URL_SLUGS: dict[int, str] = {s["id"]: s["slug"] for s in HARDCOVER_STATUSES} HARDCOVER_STATUS_GROUP = "Reading Status" @@ -402,15 +415,20 @@ HARDCOVER_WRITABLE_TARGET_GROUPS = {HARDCOVER_STATUS_GROUP, "My Lists"} @dataclass(frozen=True) class HardcoverBookTargetState: """Current Hardcover target state for a specific book.""" - user_book_id: Optional[int] - status_id: Optional[int] - list_book_ids: Dict[int, int] + + user_book_id: int | None + status_id: int | None + list_book_ids: dict[int, int] class HardcoverGraphQLError(ValueError): """GraphQL request was rejected by Hardcover.""" +class HardcoverTargetPayloadError(RuntimeError): + """Hardcover returned an invalid payload while loading book targets.""" + + def _extract_graphql_error_message(payload: Any) -> str: """Extract a readable message from a GraphQL error payload.""" if not isinstance(payload, dict): @@ -420,7 +438,7 @@ def _extract_graphql_error_message(payload: Any) -> str: if not isinstance(errors, list): return "" - messages: List[str] = [] + messages: list[str] = [] for error in errors: if not isinstance(error, dict): continue @@ -433,7 +451,7 @@ def _extract_graphql_error_message(payload: Any) -> str: # Mapping from abstract sort order to Hardcover sort parameter # Note: release_year is more consistently populated than release_date_i -SORT_MAPPING: Dict[SortOrder, str] = { +SORT_MAPPING: dict[SortOrder, str] = { SortOrder.RELEVANCE: "_text_match:desc,users_count:desc", SortOrder.POPULARITY: "users_count:desc", SortOrder.RATING: "rating:desc", @@ -442,7 +460,7 @@ SORT_MAPPING: Dict[SortOrder, str] = { } # Mapping from abstract search type to Hardcover fields parameter -SEARCH_TYPE_FIELDS: Dict[SearchType, str] = { +SEARCH_TYPE_FIELDS: dict[SearchType, str] = { SearchType.GENERAL: "title,isbns,series_names,author_names,alternative_titles", SearchType.TITLE: "title,alternative_titles", SearchType.AUTHOR: "author_names", @@ -460,14 +478,14 @@ TITLE_SUGGESTION_WEIGHTS = "5,2" TITLE_SUGGESTION_SORT = "_text_match:desc,users_count:desc" -def _combine_headline_description(headline: Optional[str], description: Optional[str]) -> Optional[str]: +def _combine_headline_description(headline: str | None, description: str | None) -> str | None: """Combine headline (tagline) and description into a single description.""" if headline and description: return f"{headline}\n\n{description}" return headline or description -def _extract_cover_url(data: Dict, *keys: str) -> Optional[str]: +def _extract_cover_url(data: dict, *keys: str) -> str | None: """Extract cover URL from data dict, trying multiple keys. Handles both string URLs and dict with 'url' key. @@ -482,22 +500,22 @@ def _extract_cover_url(data: Dict, *keys: str) -> Optional[str]: return None -def _extract_publish_year(data: Dict) -> Optional[int]: +def _extract_publish_year(data: dict) -> int | None: """Extract publish year from release_year or release_date fields.""" if data.get("release_year"): try: return int(data["release_year"]) - except (ValueError, TypeError): + except ValueError, TypeError: pass if data.get("release_date"): try: return int(str(data["release_date"])[:4]) - except (ValueError, TypeError): + except ValueError, TypeError: pass return None -def _parse_release_date(value: Any) -> Optional[datetime]: +def _parse_release_date(value: Any) -> datetime | None: """Parse Hardcover release dates stored as YYYY-MM-DD strings.""" if not value: return None @@ -512,14 +530,14 @@ def _parse_release_date(value: Any) -> Optional[datetime]: return None -def _normalize_series_position(value: Any) -> Optional[float]: +def _normalize_series_position(value: Any) -> float | None: """Normalize a series position to a float for sorting and grouping.""" if value is None: return None try: return float(value) - except (TypeError, ValueError): + except TypeError, ValueError: return None @@ -528,7 +546,7 @@ def _normalize_search_text(value: str) -> str: return " ".join(value.split()).strip() -def _unwrap_hit_document(hit: Any) -> Optional[Dict[str, Any]]: +def _unwrap_hit_document(hit: Any) -> dict[str, Any] | None: """Extract the document dict from a Typesense hit, or return None.""" if not isinstance(hit, dict): return None @@ -536,7 +554,7 @@ def _unwrap_hit_document(hit: Any) -> Optional[Dict[str, Any]]: return item if isinstance(item, dict) else None -def _search_tokens(value: str) -> List[str]: +def _search_tokens(value: str) -> list[str]: """Tokenize search text for lightweight prefix matching.""" return re.findall(r"[a-z0-9']+", value.casefold()) @@ -564,7 +582,7 @@ def _query_matches_author_name(query: str, author_name: str) -> bool: ) -def _split_part_base_title(title: str) -> Optional[str]: +def _split_part_base_title(title: str) -> str | None: """Extract the base title from segmented part releases like ', Part 2'.""" normalized_title = _normalize_search_text(title) if not normalized_title: @@ -595,7 +613,7 @@ def _series_allows_split_parts(series_name: str) -> bool: return any(marker in normalized_name for marker in markers) -def _extract_typesense_hits(result: Dict[str, Any]) -> tuple[List[Dict[str, Any]], int]: +def _extract_typesense_hits(result: dict[str, Any]) -> tuple[list[dict[str, Any]], int]: """Extract hit documents + total count from Hardcover search output.""" root = result.get("search", result) if isinstance(result, dict) else {} results_obj = root.get("results", {}) if isinstance(root, dict) else {} @@ -608,7 +626,7 @@ def _extract_typesense_hits(result: Dict[str, Any]) -> tuple[List[Dict[str, Any] return hits, found_count -def _build_source_url(slug: str) -> Optional[str]: +def _build_source_url(slug: str) -> str | None: """Build Hardcover source URL from book slug.""" return f"https://hardcover.app/books/{slug}" if slug else None @@ -617,7 +635,10 @@ def _is_probably_series_position(subtitle: str) -> bool: normalized = subtitle.strip().lower() # Common patterns: "Book One", "Book 1", "Part 2", "Volume III", etc. - if re.match(r"^(book|part|volume|vol\.?|episode)\s+([0-9]+|[ivxlcdm]+|one|two|three|four|five|six|seven|eight|nine|ten)\b", normalized): + if re.match( + r"^(book|part|volume|vol\.?|episode)\s+([0-9]+|[ivxlcdm]+|one|two|three|four|five|six|seven|eight|nine|ten)\b", + normalized, + ): return True # e.g. "A Novel", "An Epic Fantasy", etc. These add noise to indexer queries. @@ -626,15 +647,23 @@ def _is_probably_series_position(subtitle: str) -> bool: # Descriptive subtitles like "A [Name] Novel", "An [Name] Mystery", etc. genre_words = ( - "novel", "novella", "story", "memoir", "tale", "thriller", "mystery", - "romance", "adventure", "epic", "saga", "chronicle", "fantasy", + "novel", + "novella", + "story", + "memoir", + "tale", + "thriller", + "mystery", + "romance", + "adventure", + "epic", + "saga", + "chronicle", + "fantasy", "novel-in-stories", ) genre_pattern = "|".join(re.escape(w) for w in genre_words) - if re.match(rf"^an?\s+.+\s+({genre_pattern})$", normalized): - return True - - return False + return bool(re.match(rf"^an?\s+.+\s+({genre_pattern})$", normalized)) def _strip_parenthetical_suffix(title: str) -> str: @@ -642,7 +671,7 @@ def _strip_parenthetical_suffix(title: str) -> str: return re.sub(r"\s*\([^)]*\)\s*$", "", title).strip() -def _simplify_author_for_search(author: str) -> Optional[str]: +def _simplify_author_for_search(author: str) -> str | None: """Return a looser author string for indexer searches. Primary goal: reduce mismatch between metadata providers and indexers. @@ -665,7 +694,7 @@ def _simplify_author_for_search(author: str) -> Optional[str]: if "," in normalized: parts = [p.strip() for p in normalized.split(",") if p.strip()] if len(parts) >= 2: - normalized = " ".join(parts[1:] + [parts[0]]).strip() + normalized = " ".join([*parts[1:], parts[0]]).strip() tokens = normalized.split(" ") if len(tokens) < 2: @@ -703,10 +732,10 @@ def _simplify_author_for_search(author: str) -> Optional[str]: def _compute_search_title( title: str, - subtitle: Optional[str], + subtitle: str | None, *, - series_name: Optional[str] = None, -) -> Optional[str]: + series_name: str | None = None, +) -> str | None: """Compute a provider-specific, *looser* title for indexer searching. Goal: produce a string that maximizes recall in downstream sources (Prowlarr, @@ -728,7 +757,9 @@ def _compute_search_title( normalized_title = _strip_parenthetical_suffix(original_title) normalized_subtitle = " ".join(subtitle.split()).strip() if subtitle else "" - normalized_subtitle = _strip_parenthetical_suffix(normalized_subtitle) if normalized_subtitle else "" + normalized_subtitle = ( + _strip_parenthetical_suffix(normalized_subtitle) if normalized_subtitle else "" + ) if normalized_subtitle and normalized_subtitle.lower() == normalized_title.lower(): normalized_subtitle = "" @@ -738,7 +769,10 @@ def _compute_search_title( match = re.match(r"^(.+?)\s*:\s*(.+)$", normalized_title) if match: suffix = _strip_parenthetical_suffix(match.group(2).strip()) - if normalized_subtitle.lower() == suffix.lower() or normalized_subtitle.lower() in suffix.lower(): + if ( + normalized_subtitle.lower() == suffix.lower() + or normalized_subtitle.lower() in suffix.lower() + ): return None # Prefer subtitle when it looks like the real title. @@ -760,7 +794,10 @@ def _compute_search_title( return prefix # Otherwise assume "Series: Book Title" and prefer the subtitle. - if normalized_subtitle.lower() == suffix.lower() or normalized_subtitle.lower() in suffix.lower(): + if ( + normalized_subtitle.lower() == suffix.lower() + or normalized_subtitle.lower() in suffix.lower() + ): return normalized_subtitle # Fallback: if title contains the subtitle, this is likely "Series: Subtitle". @@ -774,7 +811,7 @@ def _compute_search_title( # Common Hardcover format: "Series: Book Title". prefix = f"{series_normalized}:" if normalized_title.lower().startswith(prefix.lower()): - candidate = normalized_title[len(prefix):].strip() + candidate = normalized_title[len(prefix) :].strip() candidate = _strip_parenthetical_suffix(candidate) if candidate and candidate.lower() != normalized_title.lower(): return candidate @@ -787,7 +824,7 @@ def _compute_search_title( @register_provider_kwargs("hardcover") -def _hardcover_kwargs() -> Dict[str, Any]: +def _hardcover_kwargs() -> dict[str, Any]: """Provide Hardcover-specific constructor kwargs.""" return {"api_key": app_config.get("HARDCOVER_API_KEY", "")} @@ -799,22 +836,22 @@ class HardcoverProvider(MetadataProvider): name = "hardcover" display_name = "Hardcover" requires_auth = True - supported_sorts = [ + supported_sorts: ClassVar[tuple[SortOrder, ...]] = ( SortOrder.RELEVANCE, SortOrder.POPULARITY, SortOrder.RATING, SortOrder.NEWEST, SortOrder.OLDEST, SortOrder.SERIES_ORDER, - ] - capabilities = [ + ) + capabilities: ClassVar[tuple[MetadataCapability, ...]] = ( MetadataCapability( key="view_series", field_key="series", sort=SortOrder.SERIES_ORDER, ), - ] - search_fields = [ + ) + search_fields: ClassVar[tuple[TextSearchField | DynamicSelectSearchField, ...]] = ( TextSearchField( key="author", label="Author", @@ -841,19 +878,21 @@ class HardcoverProvider(MetadataProvider): placeholder="Browse a list...", description="Browse books from a Hardcover list", ), - ] + ) - def __init__(self, api_key: Optional[str] = None): + def __init__(self, api_key: str | None = None) -> None: """Initialize provider with optional API key (falls back to config).""" raw_key = api_key or app_config.get("HARDCOVER_API_KEY", "") # Strip "Bearer " prefix if user pasted the full auth header from Hardcover self.api_key = raw_key.removeprefix("Bearer ").strip() if raw_key else "" self.session = requests.Session() if self.api_key: - self.session.headers.update({ - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - }) + self.session.headers.update( + { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + ) def is_available(self) -> bool: """Check if provider is configured with an API key.""" @@ -861,7 +900,7 @@ class HardcoverProvider(MetadataProvider): def _build_search_params( self, default_query: str, author: str, title: str, series: str - ) -> tuple[str, Optional[str], Optional[str]]: + ) -> tuple[str, str | None, str | None]: """Build search query, fields, and weights based on provided values. Returns (query, fields, weights) tuple. Fields/weights are None for general search. @@ -874,7 +913,7 @@ class HardcoverProvider(MetadataProvider): return f"{title} {author}", "title,alternative_titles,author_names", "5,1,3" return default_query, None, None - def _detect_list_url(self, query: str) -> Optional[tuple[Optional[str], str]]: + def _detect_list_url(self, query: str) -> tuple[str | None, str] | None: """Detect and extract optional owner username + list slug from a URL string.""" candidate = query.strip() if not candidate: @@ -931,16 +970,18 @@ class HardcoverProvider(MetadataProvider): source_title = str(list_data.get("name") or "").strip() or None list_slug = str(list_data.get("slug") or "").strip() user_data = list_data.get("user", {}) - owner_username = str(user_data.get("username") or "").strip() if isinstance(user_data, dict) else "" + owner_username = ( + str(user_data.get("username") or "").strip() if isinstance(user_data, dict) else "" + ) if list_slug and owner_username: source_url = f"https://hardcover.app/@{owner_username}/lists/{list_slug}" try: books_count = int(books_count_raw) - except (TypeError, ValueError): + except TypeError, ValueError: books_count = 0 - books: List[BookMetadata] = [] + books: list[BookMetadata] = [] for item in list_books: if not isinstance(item, dict): continue @@ -951,14 +992,25 @@ class HardcoverProvider(MetadataProvider): parsed_book = self._parse_book(book_data) if parsed_book: books.append(parsed_book) - except Exception as exc: - logger.debug(f"Failed to parse Hardcover list book for list_id={list_id}: {exc}") + except (AttributeError, IndexError, KeyError, TypeError, ValueError) as exc: + logger.debug("Failed to parse Hardcover list book for list_id=%s: %s", list_id, exc) has_more = offset + len(list_books) < books_count - return SearchResult(books=books, page=page, total_found=books_count, has_more=has_more, source_url=source_url, source_title=source_title) + return SearchResult( + books=books, + page=page, + total_found=books_count, + has_more=has_more, + source_url=source_url, + source_title=source_title, + ) - @cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:list:slug") - def _fetch_list_books(self, slug: str, owner_username: Optional[str], page: int, limit: int) -> SearchResult: + @cacheable( + ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:list:slug" + ) + def _fetch_list_books( + self, slug: str, owner_username: str | None, page: int, limit: int + ) -> SearchResult: """Fetch list books by slug, optionally disambiguating by owner username.""" if not self.api_key: return SearchResult(books=[], page=page, total_found=0, has_more=False) @@ -971,7 +1023,7 @@ class HardcoverProvider(MetadataProvider): if not isinstance(lists, list) or not lists: return SearchResult(books=[], page=page, total_found=0, has_more=False) - selected: Optional[Dict[str, Any]] = None + selected: dict[str, Any] | None = None normalized_owner = owner_username.lower() if owner_username else None if normalized_owner: for item in lists: @@ -995,12 +1047,12 @@ class HardcoverProvider(MetadataProvider): list_id_raw = selected.get("id") try: list_id = int(list_id_raw) - except (TypeError, ValueError): + except TypeError, ValueError: return SearchResult(books=[], page=page, total_found=0, has_more=False) return self._fetch_list_books_by_id(list_id, page, limit) - def _resolve_current_user_id(self) -> Optional[str]: + def _resolve_current_user_id(self) -> str | None: """Resolve current Hardcover user id from saved settings or API me query.""" connected_user_id = _get_connected_user_id() if connected_user_id: @@ -1026,7 +1078,7 @@ class HardcoverProvider(MetadataProvider): _save_connected_user(user_id, username) return user_id - def get_user_lists(self) -> List[Dict[str, str]]: + def get_user_lists(self) -> list[dict[str, str]]: """Get authenticated user's own and followed Hardcover lists.""" if not self.api_key: return [] @@ -1040,8 +1092,8 @@ class HardcoverProvider(MetadataProvider): def get_search_field_options( self, field_key: str, - query: Optional[str] = None, - ) -> List[Dict[str, str]]: + query: str | None = None, + ) -> list[dict[str, str]]: """Provide dynamic options for Hardcover-specific advanced fields.""" if field_key == "author": return self._search_author_options(query or "") @@ -1059,10 +1111,10 @@ class HardcoverProvider(MetadataProvider): query: str, query_type: str, limit: int, - sort: Optional[str], - fields: Optional[str], - weights: Optional[str], - ) -> List[Dict[str, Any]]: + sort: str | None, + fields: str | None, + weights: str | None, + ) -> list[dict[str, Any]]: """Run a Hardcover search request for field-level typeahead options.""" normalized_query = _normalize_search_text(query) if not self.api_key or len(normalized_query) < 2: @@ -1086,7 +1138,7 @@ class HardcoverProvider(MetadataProvider): hits, _found_count = _extract_typesense_hits(result) return hits - def _search_series_by_matching_author(self, query: str) -> List[Dict[str, Any]]: + def _search_series_by_matching_author(self, query: str) -> list[dict[str, Any]]: """Return direct series rows when the query clearly matches an author.""" author_hits = self._search_field_hits( query=query, @@ -1097,7 +1149,7 @@ class HardcoverProvider(MetadataProvider): weights=AUTHOR_SUGGESTION_WEIGHTS, ) - author_ids: List[int] = [] + author_ids: list[int] = [] for hit in author_hits: item = _unwrap_hit_document(hit) if item is None: @@ -1109,7 +1161,7 @@ class HardcoverProvider(MetadataProvider): try: author_id = int(item.get("id")) - except (TypeError, ValueError): + except TypeError, ValueError: continue if author_id not in author_ids: @@ -1132,7 +1184,7 @@ class HardcoverProvider(MetadataProvider): return [row for row in series_rows if isinstance(row, dict)] @cacheable(ttl=120, key_prefix="hardcover:author:options") - def _search_author_options(self, query: str) -> List[Dict[str, str]]: + def _search_author_options(self, query: str) -> list[dict[str, str]]: """Return typeahead options for Hardcover author search.""" hits = self._search_field_hits( query=query, @@ -1142,7 +1194,7 @@ class HardcoverProvider(MetadataProvider): fields=AUTHOR_SUGGESTION_FIELDS, weights=AUTHOR_SUGGESTION_WEIGHTS, ) - options: List[Dict[str, str]] = [] + options: list[dict[str, str]] = [] seen_labels: set[str] = set() for hit in hits: @@ -1161,7 +1213,7 @@ class HardcoverProvider(MetadataProvider): return options @cacheable(ttl=120, key_prefix="hardcover:title:options") - def _search_title_options(self, query: str) -> List[Dict[str, str]]: + def _search_title_options(self, query: str) -> list[dict[str, str]]: """Return typeahead options for Hardcover title search.""" hits = self._search_field_hits( query=query, @@ -1176,7 +1228,7 @@ class HardcoverProvider(MetadataProvider): exclude_unreleased = app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False) current_year = datetime.now().year - options: List[Dict[str, str]] = [] + options: list[dict[str, str]] = [] seen_labels: set[str] = set() for hit in hits: @@ -1192,7 +1244,7 @@ class HardcoverProvider(MetadataProvider): try: if release_year is not None and int(release_year) > current_year: continue - except (TypeError, ValueError): + except TypeError, ValueError: pass label = str(item.get("title") or "").strip() @@ -1205,7 +1257,7 @@ class HardcoverProvider(MetadataProvider): return options - def _format_series_option_description(self, item: Dict[str, Any]) -> Optional[str]: + def _format_series_option_description(self, item: dict[str, Any]) -> str | None: """Build a short description for a series suggestion option.""" author_name = item.get("author_name") if not author_name: @@ -1213,7 +1265,7 @@ class HardcoverProvider(MetadataProvider): if isinstance(author_data, dict): author_name = author_data.get("name") - parts: List[str] = [] + parts: list[str] = [] if author_name: parts.append(f"by {author_name}") @@ -1225,15 +1277,15 @@ class HardcoverProvider(MetadataProvider): if books_count is not None: books_count_int = int(books_count) parts.append(f"{books_count_int} book{'s' if books_count_int != 1 else ''}") - except (TypeError, ValueError): + except TypeError, ValueError: pass return " • ".join(parts) if parts else None @cacheable(ttl=120, key_prefix="hardcover:series:options") - def _search_series_options(self, query: str) -> List[Dict[str, str]]: + def _search_series_options(self, query: str) -> list[dict[str, str]]: """Return typeahead options for Hardcover series search.""" - from concurrent.futures import ThreadPoolExecutor, as_completed + from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=2) as executor: author_future = executor.submit(self._search_series_by_matching_author, query) @@ -1249,18 +1301,14 @@ class HardcoverProvider(MetadataProvider): author_series = author_future.result() hits = series_future.result() - options: List[Dict[str, str]] = [] + options: list[dict[str, str]] = [] seen_values: set[str] = set() - series_items: List[Dict[str, Any]] = [] + series_items: list[dict[str, Any]] = [] series_items.extend(author_series) - series_items.extend( - doc for hit in hits - if (doc := _unwrap_hit_document(hit)) is not None - ) + series_items.extend(doc for hit in hits if (doc := _unwrap_hit_document(hit)) is not None) for item in series_items: - series_id = item.get("id") name = str(item.get("name") or "").strip() if series_id is None or not name: @@ -1271,7 +1319,7 @@ class HardcoverProvider(MetadataProvider): continue seen_values.add(value) - option: Dict[str, str] = { + option: dict[str, str] = { "value": value, "label": name, } @@ -1284,7 +1332,7 @@ class HardcoverProvider(MetadataProvider): return options - def _resolve_series_search_value(self, series_value: str) -> Optional[Dict[str, Any]]: + def _resolve_series_search_value(self, series_value: str) -> dict[str, Any] | None: """Resolve a series field value to a canonical Hardcover series.""" normalized_value = _normalize_search_text(series_value) if not normalized_value: @@ -1294,7 +1342,7 @@ class HardcoverProvider(MetadataProvider): try: return {"id": self._parse_prefixed_int(normalized_value, "series id")} except ValueError: - logger.debug(f"Invalid Hardcover series id field value: {normalized_value}") + logger.debug("Invalid Hardcover series id field value: %s", normalized_value) return None result = self._execute_query( @@ -1317,14 +1365,14 @@ class HardcoverProvider(MetadataProvider): return None normalized_lookup = normalized_value.lower() - candidates: List[Dict[str, Any]] = [] + candidates: list[dict[str, Any]] = [] for hit in hits: item = _unwrap_hit_document(hit) if item is None: continue try: series_id = int(item.get("id")) - except (TypeError, ValueError): + except TypeError, ValueError: continue name = str(item.get("name") or "").strip() if not name: @@ -1335,20 +1383,27 @@ class HardcoverProvider(MetadataProvider): return None exact_match = next( - (candidate for candidate in candidates if candidate["name"].lower() == normalized_lookup), + ( + candidate + for candidate in candidates + if candidate["name"].lower() == normalized_lookup + ), None, ) return exact_match or candidates[0] - @cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:series:rows:v4") + @cacheable( + ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:series:rows:v4" + ) def _fetch_series_ordered_rows( self, series_id: int, + *, exclude_compilations: bool, exclude_unreleased: bool, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Fetch and process all books for a series (cached independently of page).""" - empty: Dict[str, Any] = {"rows": [], "series_name": "", "total": 0} + empty: dict[str, Any] = {"rows": [], "series_name": "", "total": 0} if not self.api_key: return empty @@ -1364,12 +1419,16 @@ class HardcoverProvider(MetadataProvider): return empty series_data = series_items[0] if isinstance(series_items[0], dict) else {} - series_name = str(series_data.get("name") or "").strip() if isinstance(series_data, dict) else "" + series_name = ( + str(series_data.get("name") or "").strip() if isinstance(series_data, dict) else "" + ) allow_split_parts = _series_allows_split_parts(series_name) today = datetime.now().date() - book_series_rows = series_data.get("book_series", []) if isinstance(series_data, dict) else [] - rows_by_position: Dict[float, Dict[str, Any]] = {} + book_series_rows = ( + series_data.get("book_series", []) if isinstance(series_data, dict) else [] + ) + rows_by_position: dict[float, dict[str, Any]] = {} for row in book_series_rows: if not isinstance(row, dict): continue @@ -1415,19 +1474,24 @@ class HardcoverProvider(MetadataProvider): series_id: int, page: int, limit: int, + *, exclude_compilations: bool, exclude_unreleased: bool, ) -> SearchResult: """Fetch books for a Hardcover series in canonical series order.""" - cached = self._fetch_series_ordered_rows(series_id, exclude_compilations, exclude_unreleased) + cached = self._fetch_series_ordered_rows( + series_id, + exclude_compilations=exclude_compilations, + exclude_unreleased=exclude_unreleased, + ) ordered_rows = cached["rows"] series_name = cached["series_name"] total_found = cached["total"] offset = (page - 1) * limit - page_rows = ordered_rows[offset:offset + limit] + page_rows = ordered_rows[offset : offset + limit] - books: List[BookMetadata] = [] + books: list[BookMetadata] = [] for row in page_rows: book_data = row.get("book", {}) if not isinstance(book_data, dict) or not book_data: @@ -1442,18 +1506,22 @@ class HardcoverProvider(MetadataProvider): parsed_book.series_position = row.get("position") parsed_book.series_count = total_found books.append(parsed_book) - except Exception as exc: - logger.debug(f"Failed to parse Hardcover series book for series_id={series_id}: {exc}") + except (AttributeError, IndexError, KeyError, TypeError, ValueError) as exc: + logger.debug( + "Failed to parse Hardcover series book for series_id=%s: %s", series_id, exc + ) has_more = offset + len(page_rows) < total_found return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more) @cacheable(ttl=120, key_prefix="hardcover:user_lists") - def _get_user_lists_cached(self, _cache_user_id: str) -> List[Dict[str, str]]: + def _get_user_lists_cached(self, _cache_user_id: str) -> list[dict[str, str]]: """Cached wrapper keyed by Hardcover user id to avoid cross-user cache leakage.""" return self._fetch_user_lists() - def _fetch_current_user_books_by_status(self, status_id: int, page: int, limit: int) -> SearchResult: + def _fetch_current_user_books_by_status( + self, status_id: int, page: int, limit: int + ) -> SearchResult: """Fetch the current user's Hardcover books for a specific status shelf.""" if not self.api_key: return SearchResult(books=[], page=page, total_found=0, has_more=False) @@ -1464,7 +1532,11 @@ class HardcoverProvider(MetadataProvider): return self._fetch_user_books_by_status_cached(connected_user_id, status_id, page, limit) - @cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:user_books:status") + @cacheable( + ttl_key="METADATA_CACHE_SEARCH_TTL", + ttl_default=300, + key_prefix="hardcover:user_books:status", + ) def _fetch_user_books_by_status_cached( self, _cache_user_id: str, @@ -1505,10 +1577,10 @@ class HardcoverProvider(MetadataProvider): try: total_found = int(count_raw) - except (TypeError, ValueError): + except TypeError, ValueError: total_found = 0 - books: List[BookMetadata] = [] + books: list[BookMetadata] = [] for item in status_books: if not isinstance(item, dict): continue @@ -1519,8 +1591,10 @@ class HardcoverProvider(MetadataProvider): parsed_book = self._parse_book(book_data) if parsed_book: books.append(parsed_book) - except Exception as exc: - logger.debug(f"Failed to parse Hardcover status book for status_id={status_id}: {exc}") + except (AttributeError, KeyError, TypeError, ValueError) as exc: + logger.debug( + "Failed to parse Hardcover status book for status_id=%s: %s", status_id, exc + ) has_more = offset + len(status_books) < total_found @@ -1531,9 +1605,15 @@ class HardcoverProvider(MetadataProvider): if url_slug and username: source_url = f"https://hardcover.app/@{username}/books/{url_slug}" - return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more, source_url=source_url) + return SearchResult( + books=books, + page=page, + total_found=total_found, + has_more=has_more, + source_url=source_url, + ) - def _fetch_user_lists(self) -> List[Dict[str, str]]: + def _fetch_user_lists(self) -> list[dict[str, str]]: """Fetch raw list options from Hardcover me query.""" result = self._execute_query(USER_LISTS_QUERY, {}) if not result: @@ -1545,28 +1625,20 @@ class HardcoverProvider(MetadataProvider): if not isinstance(me_data, dict): return [] - options: List[Dict[str, str]] = [] + options: list[dict[str, str]] = [] seen_values: set[str] = set() current_username = str(me_data.get("username") or "").strip() def _format_label(name: str, books_count: Any) -> str: try: return f"{name} ({int(books_count)})" - except (TypeError, ValueError): + except TypeError, ValueError: return name for status in HARDCOVER_STATUSES: count_data = me_data.get(status["query_key"], {}) - aggregate = ( - count_data.get("aggregate", {}) - if isinstance(count_data, dict) - else {} - ) - count = ( - aggregate.get("count") - if isinstance(aggregate, dict) - else None - ) + aggregate = count_data.get("aggregate", {}) if isinstance(count_data, dict) else {} + count = aggregate.get("count") if isinstance(aggregate, dict) else None value = f"{HARDCOVER_STATUS_PREFIX}{status['id']}" seen_values.add(value) options.append( @@ -1611,7 +1683,7 @@ class HardcoverProvider(MetadataProvider): continue seen_values.add(value) - option: Dict[str, str] = { + option: dict[str, str] = { "value": value, "label": _format_label(name, list_item.get("books_count")), "group": "Followed Lists", @@ -1627,7 +1699,7 @@ class HardcoverProvider(MetadataProvider): return options - def get_book_targets(self, book_id: str) -> List[Dict[str, Any]]: + def get_book_targets(self, book_id: str) -> list[dict[str, Any]]: """Get writable Hardcover list/status targets for a specific book.""" if not self.api_key: return [] @@ -1650,7 +1722,7 @@ class HardcoverProvider(MetadataProvider): return options - def set_book_target_state(self, book_id: str, target: str, selected: bool) -> Dict[str, Any]: + def set_book_target_state(self, book_id: str, target: str, selected: bool) -> dict[str, Any]: """Set whether a Hardcover book belongs to a status shelf or user list.""" if not self.api_key: raise ValueError("Hardcover is not configured") @@ -1669,12 +1741,17 @@ class HardcoverProvider(MetadataProvider): state = self._fetch_book_target_state(book_id_int) status_ids_to_invalidate: set[int] = set() list_ids_to_invalidate: set[int] = set() - deselected_target: Optional[str] = None + deselected_target: str | None = None if selected_target.startswith(HARDCOVER_STATUS_PREFIX): status_id = self._parse_prefixed_int(selected_target, "status target") previous_status_id = state.status_id - changed = self._set_status_target_state(book_id_int, status_id, selected, state) + changed = self._set_status_target_state( + book_id_int, + status_id, + selected=selected, + state=state, + ) if changed: if previous_status_id is not None: status_ids_to_invalidate.add(previous_status_id) @@ -1683,7 +1760,12 @@ class HardcoverProvider(MetadataProvider): status_ids_to_invalidate.add(status_id) elif selected_target.startswith(HARDCOVER_LIST_ID_PREFIX): list_id = self._parse_prefixed_int(selected_target, "list target") - changed = self._set_list_target_state(book_id_int, list_id, selected, state) + changed = self._set_list_target_state( + book_id_int, + list_id, + selected=selected, + state=state, + ) if changed: list_ids_to_invalidate.add(list_id) else: @@ -1696,22 +1778,22 @@ class HardcoverProvider(MetadataProvider): list_ids=list_ids_to_invalidate, ) - result_data: Dict[str, Any] = {"changed": changed} + result_data: dict[str, Any] = {"changed": changed} if deselected_target: result_data["deselected_target"] = deselected_target return result_data @staticmethod - def _unwrap_me_data(result: Optional[Dict]) -> Dict: + def _unwrap_me_data(result: dict | None) -> dict: """Extract and validate the ``me`` payload from a GraphQL result.""" if not isinstance(result, dict): - raise RuntimeError("Hardcover could not load book targets") + raise HardcoverTargetPayloadError("Hardcover could not load book targets") me_data = result.get("me", {}) if isinstance(me_data, list) and me_data: me_data = me_data[0] if not isinstance(me_data, dict): - raise RuntimeError("Hardcover returned an invalid target payload") + raise HardcoverTargetPayloadError("Hardcover returned an invalid target payload") return me_data def _fetch_book_target_state(self, book_id: int) -> HardcoverBookTargetState: @@ -1723,15 +1805,15 @@ class HardcoverProvider(MetadataProvider): ) me_data = self._unwrap_me_data(result) - user_book_id: Optional[int] = None - status_id: Optional[int] = None + user_book_id: int | None = None + status_id: int | None = None user_books = me_data.get("user_books", []) if isinstance(user_books, list) and user_books: latest_user_book = user_books[0] if isinstance(user_books[0], dict) else {} user_book_id = coerce_int(latest_user_book.get("id"), 0) or None status_id = coerce_int(latest_user_book.get("status_id"), 0) or None - list_book_ids: Dict[int, int] = {} + list_book_ids: dict[int, int] = {} for user_list in me_data.get("lists", []): if not isinstance(user_list, dict): continue @@ -1755,8 +1837,9 @@ class HardcoverProvider(MetadataProvider): ) def _fetch_book_target_states_batch( - self, book_ids: List[int], - ) -> Dict[int, HardcoverBookTargetState]: + self, + book_ids: list[int], + ) -> dict[int, HardcoverBookTargetState]: """Load Hardcover membership state for multiple books in one query.""" result = self._execute_query( BOOK_TARGET_MEMBERSHIP_BATCH_QUERY, @@ -1766,7 +1849,7 @@ class HardcoverProvider(MetadataProvider): me_data = self._unwrap_me_data(result) # Group user_books by book_id (keep only the latest per book) - user_book_by_book: Dict[int, Dict] = {} + user_book_by_book: dict[int, dict] = {} for ub in me_data.get("user_books", []): if not isinstance(ub, dict): continue @@ -1775,7 +1858,7 @@ class HardcoverProvider(MetadataProvider): user_book_by_book[bid] = ub # Group list_book memberships by book_id - list_book_ids_by_book: Dict[int, Dict[int, int]] = {} + list_book_ids_by_book: dict[int, dict[int, int]] = {} for user_list in me_data.get("lists", []): if not isinstance(user_list, dict): continue @@ -1790,7 +1873,7 @@ class HardcoverProvider(MetadataProvider): if bid > 0 and lb_id > 0: list_book_ids_by_book.setdefault(bid, {})[list_id] = lb_id - states: Dict[int, HardcoverBookTargetState] = {} + states: dict[int, HardcoverBookTargetState] = {} for bid in book_ids: ub = user_book_by_book.get(bid) states[bid] = HardcoverBookTargetState( @@ -1800,13 +1883,13 @@ class HardcoverProvider(MetadataProvider): ) return states - def get_book_targets_batch(self, book_ids: List[str]) -> Dict[str, List[Dict[str, Any]]]: + def get_book_targets_batch(self, book_ids: list[str]) -> dict[str, list[dict[str, Any]]]: """Get writable Hardcover list/status targets for multiple books.""" if not self.api_key or not book_ids: return {bid: [] for bid in book_ids} int_ids = [] - id_map: Dict[int, str] = {} + id_map: dict[int, str] = {} for bid in book_ids: int_id = coerce_int(bid, 0) if int_id > 0: @@ -1823,11 +1906,16 @@ class HardcoverProvider(MetadataProvider): if option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS ] - results: Dict[str, List[Dict[str, Any]]] = {} + results: dict[str, list[dict[str, Any]]] = {} for int_id, str_id in id_map.items(): - state = states.get(int_id, HardcoverBookTargetState( - user_book_id=None, status_id=None, list_book_ids={}, - )) + state = states.get( + int_id, + HardcoverBookTargetState( + user_book_id=None, + status_id=None, + list_book_ids={}, + ), + ) options = [dict(opt) for opt in writable_options] for option in options: value = str(option.get("value") or "").strip() @@ -1850,10 +1938,7 @@ class HardcoverProvider(MetadataProvider): if ( option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS and value - and ( - value.startswith(HARDCOVER_STATUS_PREFIX) - or value.startswith(HARDCOVER_LIST_ID_PREFIX) - ) + and value.startswith((HARDCOVER_STATUS_PREFIX, HARDCOVER_LIST_ID_PREFIX)) ): writable_targets.add(value) return writable_targets @@ -1870,6 +1955,7 @@ class HardcoverProvider(MetadataProvider): self, book_id: int, status_id: int, + *, selected: bool, state: HardcoverBookTargetState, ) -> bool: @@ -1910,6 +1996,7 @@ class HardcoverProvider(MetadataProvider): self, book_id: int, list_id: int, + *, selected: bool, state: HardcoverBookTargetState, ) -> bool: @@ -1942,7 +2029,7 @@ class HardcoverProvider(MetadataProvider): def _invalidate_book_target_caches( self, *, - connected_user_id: Optional[str], + connected_user_id: str | None, status_ids: set[int], list_ids: set[int], ) -> None: @@ -1986,7 +2073,7 @@ class HardcoverProvider(MetadataProvider): return raise RuntimeError("Hardcover could not complete this action") - def search(self, options: MetadataSearchOptions) -> List[BookMetadata]: + def search(self, options: MetadataSearchOptions) -> list[BookMetadata]: """Search for books using Hardcover's search API.""" return self.search_paginated(options).books @@ -2008,16 +2095,18 @@ class HardcoverProvider(MetadataProvider): if list_value_from_field.startswith(HARDCOVER_STATUS_PREFIX): try: status_id = self._parse_prefixed_int(list_value_from_field, "status") - return self._fetch_current_user_books_by_status(status_id, options.page, options.limit) + return self._fetch_current_user_books_by_status( + status_id, options.page, options.limit + ) except ValueError: - logger.debug(f"Invalid Hardcover status field value: {list_value_from_field}") + logger.debug("Invalid Hardcover status field value: %s", list_value_from_field) return SearchResult(books=[], page=options.page, total_found=0, has_more=False) if list_value_from_field.startswith(HARDCOVER_LIST_ID_PREFIX): try: list_id = self._parse_prefixed_int(list_value_from_field, "list") return self._fetch_list_books_by_id(list_id, options.page, options.limit) except ValueError: - logger.debug(f"Invalid hardcover_list field value: {list_value_from_field}") + logger.debug("Invalid hardcover_list field value: %s", list_value_from_field) return SearchResult(books=[], page=options.page, total_found=0, has_more=False) return self._fetch_list_books(list_value_from_field, None, options.page, options.limit) @@ -2032,8 +2121,8 @@ class HardcoverProvider(MetadataProvider): int(resolved_series["id"]), options.page, options.limit, - exclude_compilations, - exclude_unreleased, + exclude_compilations=exclude_compilations, + exclude_unreleased=exclude_unreleased, ) # Handle ISBN search separately @@ -2122,25 +2211,27 @@ class HardcoverProvider(MetadataProvider): if book: books.append(book) - logger.info(f"Hardcover search '{query}' (fields={search_fields}) returned {len(books)} results") + logger.info( + "Hardcover search '%s' (fields=%s) returned %s results", + query, + search_fields, + len(books), + ) # Calculate if there are more results results_so_far = (options.page - 1) * HARDCOVER_PAGE_SIZE + len(hits) has_more = results_so_far < found_count return SearchResult( - books=books, - page=options.page, - total_found=found_count, - has_more=has_more + books=books, page=options.page, total_found=found_count, has_more=has_more ) - except Exception as e: - logger.error(f"Hardcover search error: {e}") + except AttributeError, KeyError, TypeError, ValueError: + logger.exception("Hardcover search error") return SearchResult(books=[], page=options.page, total_found=0, has_more=False) @cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="hardcover:book") - def get_book(self, book_id: str) -> Optional[BookMetadata]: + def get_book(self, book_id: str) -> BookMetadata | None: """Get book details by Hardcover ID.""" if not self.api_key: logger.warning("Hardcover API key not configured") @@ -2211,14 +2302,14 @@ class HardcoverProvider(MetadataProvider): return self._parse_book(books[0]) except ValueError: - logger.error(f"Invalid book ID: {book_id}") + logger.exception("Invalid book ID: %s", book_id) return None - except Exception as e: - logger.error(f"Hardcover get_book error: {e}") + except AttributeError, KeyError, TypeError: + logger.exception("Hardcover get_book error") return None @cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="hardcover:isbn") - def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]: + def search_by_isbn(self, isbn: str) -> BookMetadata | None: """Search for a book by ISBN-10 or ISBN-13.""" if not self.api_key: logger.warning("Hardcover API key not configured") @@ -2270,7 +2361,7 @@ class HardcoverProvider(MetadataProvider): editions = result.get("editions", []) if not editions: - logger.debug(f"No Hardcover book found for ISBN: {isbn}") + logger.debug("No Hardcover book found for ISBN: %s", isbn) return None edition = editions[0] @@ -2284,18 +2375,22 @@ class HardcoverProvider(MetadataProvider): return self._parse_book(book_data) - except Exception as e: - logger.error(f"Hardcover ISBN search error: {e}") + except AttributeError, IndexError, KeyError, TypeError, ValueError: + logger.exception("Hardcover ISBN search error") return None def _execute_query( self, query: str, - variables: Dict[str, Any], + variables: dict[str, Any], *, raise_on_error: bool = False, - ) -> Optional[Dict]: + ) -> dict | None: """Execute a GraphQL query and return data or None on error.""" + + def _raise_graphql_error(message: str) -> None: + raise HardcoverGraphQLError(message) + try: response = self.session.post( HARDCOVER_API_URL, @@ -2308,43 +2403,45 @@ class HardcoverProvider(MetadataProvider): data = response.json() if "errors" in data: - logger.error(f"GraphQL errors: {data['errors']}") + logger.error("GraphQL errors: %s", data["errors"]) if raise_on_error: - message = _extract_graphql_error_message(data) or "Hardcover rejected this request" - raise HardcoverGraphQLError(message) + message = ( + _extract_graphql_error_message(data) or "Hardcover rejected this request" + ) + _raise_graphql_error(message) return None return data.get("data") - except requests.Timeout: + except requests.Timeout as e: logger.warning("Hardcover API request timed out") if raise_on_error: - raise RuntimeError("Hardcover API request timed out") + raise RuntimeError("Hardcover API request timed out") from e return None except requests.HTTPError as e: if e.response.status_code == 401: - logger.error("Hardcover API key is invalid") + logger.exception("Hardcover API key is invalid") if raise_on_error: - raise RuntimeError("Hardcover API key is invalid") + raise RuntimeError("Hardcover API key is invalid") from e else: - logger.error(f"Hardcover API HTTP error: {e}") + logger.exception("Hardcover API HTTP error") if raise_on_error: - raise RuntimeError(f"Hardcover API HTTP error: {e}") + raise RuntimeError(f"Hardcover API HTTP error: {e}") from e return None except HardcoverGraphQLError: raise except ValueError as e: - logger.error(f"Hardcover API returned invalid JSON: {e}") + logger.exception("Hardcover API returned invalid JSON") if raise_on_error: raise RuntimeError("Hardcover API returned an invalid response") from e return None - except Exception as e: - logger.error(f"Hardcover API request failed: {e}") + except (TypeError, requests.RequestException) as e: + logger.exception("Hardcover API request failed") if raise_on_error: raise RuntimeError("Hardcover API request failed") from e return None - def _parse_search_result(self, item: Dict) -> Optional[BookMetadata]: + def _parse_search_result(self, item: dict) -> BookMetadata | None: """Parse a search result item into BookMetadata.""" try: book_id = item.get("id") or item.get("document", {}).get("id") @@ -2364,7 +2461,7 @@ class HardcoverProvider(MetadataProvider): # If we have parallel arrays, filter to only "Author" contributions if contribution_types and len(contribution_types) == len(author_names): - for name, contrib_type in zip(author_names, contribution_types): + for name, contrib_type in zip(author_names, contribution_types, strict=True): if contrib_type == "Author": authors.append(name) elif author_names: @@ -2395,7 +2492,9 @@ class HardcoverProvider(MetadataProvider): # Readers (users who have this book) users_count = item.get("users_count") if users_count: - display_fields.append(DisplayField(label="Readers", value=f"{users_count:,}", icon="users")) + display_fields.append( + DisplayField(label="Readers", value=f"{users_count:,}", icon="users") + ) # Combine headline and description if both present headline = item.get("headline") @@ -2421,12 +2520,11 @@ class HardcoverProvider(MetadataProvider): display_fields=display_fields, ) - - except Exception as e: - logger.debug(f"Failed to parse Hardcover search result: {e}") + except (AttributeError, KeyError, TypeError, ValueError) as e: + logger.debug("Failed to parse Hardcover search result: %s", e) return None - def _parse_book(self, book: Dict) -> BookMetadata: + def _parse_book(self, book: dict) -> BookMetadata: """Parse a book object into BookMetadata.""" title = str(book.get("title") or "") subtitle = book.get("subtitle") @@ -2516,7 +2614,7 @@ class HardcoverProvider(MetadataProvider): # Extract titles by language from editions # This allows searching with localized titles when language filter is active - titles_by_language: Dict[str, str] = {} + titles_by_language: dict[str, str] = {} editions = book.get("editions", []) for edition in editions: edition_title = edition.get("title") @@ -2539,21 +2637,19 @@ class HardcoverProvider(MetadataProvider): titles_by_language[code3] = edition_title # Build display fields from Hardcover-specific metrics - display_fields: List[DisplayField] = [] + display_fields: list[DisplayField] = [] rating = book.get("rating") ratings_count = book.get("ratings_count") if rating is not None: try: rating_str = f"{float(rating):.1f}" - except (TypeError, ValueError): + except TypeError, ValueError: rating_str = str(rating) if ratings_count: - try: + with suppress(TypeError, ValueError): rating_str += f" ({int(ratings_count):,})" - except (TypeError, ValueError): - pass display_fields.append(DisplayField(label="Rating", value=rating_str, icon="star")) @@ -2561,37 +2657,36 @@ class HardcoverProvider(MetadataProvider): if users_count: try: readers_value = f"{int(users_count):,}" - except (TypeError, ValueError): + except TypeError, ValueError: readers_value = str(users_count) display_fields.append(DisplayField(label="Readers", value=readers_value, icon="users")) return BookMetadata( - provider="hardcover", - provider_id=str(book["id"]), - title=title, - subtitle=subtitle, - search_title=_compute_search_title(title, subtitle, series_name=series_name), - search_author=search_author, - provider_display_name="Hardcover", - authors=authors, - isbn_10=isbn_10, - isbn_13=isbn_13, - cover_url=cover_url, - description=full_description, - publish_year=publish_year, - genres=genres, - source_url=source_url, - series_id=series_id, - series_name=series_name, - series_position=series_position, - series_count=series_count, - titles_by_language=titles_by_language, - display_fields=display_fields, - ) + provider="hardcover", + provider_id=str(book["id"]), + title=title, + subtitle=subtitle, + search_title=_compute_search_title(title, subtitle, series_name=series_name), + search_author=search_author, + provider_display_name="Hardcover", + authors=authors, + isbn_10=isbn_10, + isbn_13=isbn_13, + cover_url=cover_url, + description=full_description, + publish_year=publish_year, + genres=genres, + source_url=source_url, + series_id=series_id, + series_name=series_name, + series_position=series_position, + series_count=series_count, + titles_by_language=titles_by_language, + display_fields=display_fields, + ) - -def _test_hardcover_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def _test_hardcover_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: """Test the Hardcover API connection using current form values.""" from shelfmark.core.config import config as app_config @@ -2603,7 +2698,7 @@ def _test_hardcover_connection(current_values: Optional[Dict[str, Any]] = None) api_key = raw_key.removeprefix("Bearer ").strip() if raw_key else "" key_len = len(api_key) if api_key else 0 - logger.debug(f"Hardcover test: key length={key_len}") + logger.debug("Hardcover test: key length=%s", key_len) if not api_key: # Clear any stored connection metadata since there's no key @@ -2611,39 +2706,46 @@ def _test_hardcover_connection(current_values: Optional[Dict[str, Any]] = None) return {"success": False, "message": "API key is required"} if key_len < 100: - return {"success": False, "message": f"API key seems too short ({key_len} chars). Expected 500+ chars."} + return { + "success": False, + "message": f"API key seems too short ({key_len} chars). Expected 500+ chars.", + } + connection_result = {"success": False, "message": "API request failed - check your API key"} try: provider = HardcoverProvider(api_key=api_key) # Use the 'me' query to test connection (recommended by API docs) - result = provider._execute_query( - "query { me { id, username } }", - {} - ) + result = provider._execute_query("query { me { id, username } }", {}) if result is not None: # Handle both single object and array response formats me_data = result.get("me", {}) if isinstance(me_data, list) and me_data: me_data = me_data[0] - user_id = str(me_data.get("id")) if isinstance(me_data, dict) and me_data.get("id") is not None else None - username = me_data.get("username", "Unknown") if isinstance(me_data, dict) else "Unknown" + user_id = ( + str(me_data.get("id")) + if isinstance(me_data, dict) and me_data.get("id") is not None + else None + ) + username = ( + me_data.get("username", "Unknown") if isinstance(me_data, dict) else "Unknown" + ) # Save connected user metadata for persistent display + per-user list caching _save_connected_user(user_id, username) - - return {"success": True, "message": f"Connected as: {username}"} + connection_result = {"success": True, "message": f"Connected as: {username}"} else: _save_connected_user(None, None) - return {"success": False, "message": "API request failed - check your API key"} - except Exception as e: + except (AttributeError, KeyError, requests.RequestException, TypeError, ValueError) as e: logger.exception("Hardcover connection test failed") _save_connected_user(None, None) - return {"success": False, "message": f"Connection failed: {str(e)}"} + return {"success": False, "message": f"Connection failed: {e!s}"} + + return connection_result -def _save_connected_user(user_id: Optional[str], username: Optional[str]) -> None: +def _save_connected_user(user_id: str | None, username: str | None) -> None: """Save or clear connected user metadata in config.""" - from shelfmark.core.settings_registry import save_config_file, load_config_file + from shelfmark.core.settings_registry import load_config_file, save_config_file config = load_config_file("hardcover") if user_id: @@ -2659,7 +2761,7 @@ def _save_connected_user(user_id: Optional[str], username: Optional[str]) -> Non save_config_file("hardcover", config) -def _get_connected_username() -> Optional[str]: +def _get_connected_username() -> str | None: """Get the stored connected username.""" from shelfmark.core.settings_registry import load_config_file @@ -2667,7 +2769,7 @@ def _get_connected_username() -> Optional[str]: return config.get("_connected_username") -def _get_connected_user_id() -> Optional[str]: +def _get_connected_user_id() -> str | None: """Get the stored connected Hardcover user id.""" from shelfmark.core.settings_registry import load_config_file @@ -2687,11 +2789,13 @@ _HARDCOVER_SORT_OPTIONS = [ @register_settings("hardcover", "Hardcover", icon="book", order=51, group="metadata_providers") -def hardcover_settings(): +def hardcover_settings() -> list[SettingsField]: """Hardcover metadata provider settings.""" # Check for connected username to show status connected_user = _get_connected_username() - test_button_description = f"Connected as: {connected_user}" if connected_user else "Verify your API key works" + test_button_description = ( + f"Connected as: {connected_user}" if connected_user else "Verify your API key works" + ) return [ HeadingField( diff --git a/shelfmark/metadata_providers/openlibrary.py b/shelfmark/metadata_providers/openlibrary.py index 2537d0a9..189c59ac 100644 --- a/shelfmark/metadata_providers/openlibrary.py +++ b/shelfmark/metadata_providers/openlibrary.py @@ -1,23 +1,24 @@ """Open Library metadata provider. No API key required, rate limited.""" import re -import time import threading +import time from collections import deque -from typing import Any, Deque, Dict, List, Optional +from typing import Any, ClassVar import requests from shelfmark.core.cache import cacheable from shelfmark.core.logger import setup_logger -from shelfmark.download.network import get_ssl_verify from shelfmark.core.settings_registry import ( - register_settings, - CheckboxField, - SelectField, ActionButton, + CheckboxField, HeadingField, + SelectField, + SettingsField, + register_settings, ) +from shelfmark.download.network import get_ssl_verify from shelfmark.metadata_providers import ( BookMetadata, DisplayField, @@ -25,8 +26,8 @@ from shelfmark.metadata_providers import ( MetadataSearchOptions, SearchType, SortOrder, - register_provider, TextSearchField, + register_provider, ) logger = setup_logger(__name__) @@ -43,11 +44,11 @@ RATE_LIMIT_WINDOW_SECONDS = 60 class RateLimiter: """Simple sliding window rate limiter.""" - def __init__(self, max_requests: int, window_seconds: int): + def __init__(self, max_requests: int, window_seconds: int) -> None: """Initialize rate limiter with max requests per time window.""" self.max_requests = max_requests self.window_seconds = window_seconds - self.timestamps: Deque[float] = deque() + self.timestamps: deque[float] = deque() self.lock = threading.Lock() def wait_if_needed(self) -> None: @@ -69,7 +70,7 @@ class RateLimiter: # Sleep outside the lock to avoid blocking other threads if wait_time > 0: - logger.debug(f"Rate limited, waiting {wait_time:.2f}s") + logger.debug("Rate limited, waiting %0.2fs", wait_time) time.sleep(wait_time) # Re-acquire lock and record request @@ -90,7 +91,7 @@ _rate_limiter = RateLimiter(RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS) # Mapping from abstract sort order to Open Library sort parameter # Note: Open Library only supports relevance (default), new, old, random -SORT_MAPPING: Dict[str, Optional[str]] = { +SORT_MAPPING: dict[str, str | None] = { SortOrder.RELEVANCE: None, # Default (no sort param) SortOrder.NEWEST: "new", SortOrder.OLDEST: "old", @@ -105,12 +106,12 @@ class OpenLibraryProvider(MetadataProvider): name = "openlibrary" display_name = "Open Library" requires_auth = False - supported_sorts = [ + supported_sorts: ClassVar[tuple[SortOrder, ...]] = ( SortOrder.RELEVANCE, SortOrder.NEWEST, SortOrder.OLDEST, - ] - search_fields = [ + ) + search_fields: ClassVar[tuple[TextSearchField, ...]] = ( TextSearchField( key="author", label="Author", @@ -121,9 +122,9 @@ class OpenLibraryProvider(MetadataProvider): label="Title", description="Search by book title", ), - ] + ) - def __init__(self): + def __init__(self) -> None: """Initialize provider.""" self.session = requests.Session() @@ -131,7 +132,7 @@ class OpenLibraryProvider(MetadataProvider): """Open Library is always available (no auth required).""" return True - def search(self, options: MetadataSearchOptions) -> List[BookMetadata]: + def search(self, options: MetadataSearchOptions) -> list[BookMetadata]: """Search for books using Open Library's search API.""" # Handle ISBN search separately if options.search_type == SearchType.ISBN: @@ -143,13 +144,15 @@ class OpenLibraryProvider(MetadataProvider): cache_key = f"{options.query}:{options.search_type.value}:{options.sort.value}:{options.language}:{options.limit}:{options.page}:{fields_key}" return self._search_cached(cache_key, options) - @cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="openlibrary:search") - def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> List[BookMetadata]: + @cacheable( + ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="openlibrary:search" + ) + def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> list[BookMetadata]: """Cached search implementation.""" _rate_limiter.wait_if_needed() # Build query params - params: Dict[str, Any] = { + params: dict[str, Any] = { "limit": options.limit, "page": options.page, "fields": "key,title,author_name,first_publish_year,cover_i,isbn,publisher,language,subject,ratings_average,ratings_count", @@ -185,6 +188,7 @@ class OpenLibraryProvider(MetadataProvider): if options.language: params["lang"] = options.language + books: list[BookMetadata] = [] try: response = self.session.get( f"{OPENLIBRARY_BASE_URL}/search.json", @@ -195,14 +199,12 @@ class OpenLibraryProvider(MetadataProvider): response.raise_for_status() data = response.json() - books = [] for doc in data.get("docs", []): book = self._parse_search_doc(doc) if book: books.append(book) - logger.info(f"Open Library search '{options.query}' returned {len(books)} results") - return books + logger.info("Open Library search '%s' returned %s results", options.query, len(books)) except requests.Timeout: logger.warning("Open Library search timed out") @@ -211,14 +213,18 @@ class OpenLibraryProvider(MetadataProvider): if e.response.status_code == 503: logger.warning("Open Library service unavailable (503)") else: - logger.error(f"Open Library HTTP error: {e}") + logger.exception("Open Library HTTP error") return [] - except Exception as e: - logger.error(f"Open Library search error: {e}") + except requests.RequestException: + logger.exception("Open Library search request failed") return [] + except TypeError, ValueError: + logger.exception("Open Library search parsing error") + return [] + return books @cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="openlibrary:book") - def get_book(self, book_id: str) -> Optional[BookMetadata]: + def get_book(self, book_id: str) -> BookMetadata | None: """Get book details by Open Library work ID (e.g., 'OL12345W').""" _rate_limiter.wait_if_needed() @@ -244,16 +250,19 @@ class OpenLibraryProvider(MetadataProvider): return None except requests.HTTPError as e: if e.response.status_code == 404: - logger.debug(f"Open Library work not found: {book_id}") + logger.debug("Open Library work not found: %s", book_id) else: - logger.error(f"Open Library HTTP error: {e}") + logger.exception("Open Library HTTP error") return None - except Exception as e: - logger.error(f"Open Library get_book error: {e}") + except requests.RequestException: + logger.exception("Open Library get_book request failed") + return None + except TypeError, ValueError: + logger.exception("Open Library get_book parsing error") return None @cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="openlibrary:isbn") - def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]: + def search_by_isbn(self, isbn: str) -> BookMetadata | None: """Search for a book by ISBN-10 or ISBN-13.""" # Clean ISBN clean_isbn = isbn.replace("-", "").strip() @@ -283,6 +292,7 @@ class OpenLibraryProvider(MetadataProvider): # Update with ISBN from edition if not present # Use dataclasses.replace() to avoid mutating cached object from dataclasses import replace + updates = {} if not book.isbn_10: isbn_10_list = edition.get("isbn_10", []) @@ -301,15 +311,18 @@ class OpenLibraryProvider(MetadataProvider): except requests.HTTPError as e: if e.response.status_code == 404: - logger.debug(f"Open Library ISBN not found: {isbn}") + logger.debug("Open Library ISBN not found: %s", isbn) else: - logger.error(f"Open Library ISBN search HTTP error: {e}") + logger.exception("Open Library ISBN search HTTP error") return None - except Exception as e: - logger.error(f"Open Library ISBN search error: {e}") + except requests.RequestException: + logger.exception("Open Library ISBN search request failed") + return None + except TypeError, ValueError: + logger.exception("Open Library ISBN search parsing error") return None - def _parse_search_doc(self, doc: dict) -> Optional[BookMetadata]: + def _parse_search_doc(self, doc: dict) -> BookMetadata | None: """Parse a search document into BookMetadata.""" try: # Extract work ID from key @@ -374,11 +387,11 @@ class OpenLibraryProvider(MetadataProvider): display_fields=display_fields, ) - except Exception as e: - logger.debug(f"Failed to parse Open Library search doc: {e}") + except (TypeError, ValueError, AttributeError, KeyError) as e: + logger.debug("Failed to parse Open Library search doc: %s", e) return None - def _parse_work(self, work: dict, work_id: str) -> Optional[BookMetadata]: + def _parse_work(self, work: dict, work_id: str) -> BookMetadata | None: """Parse a work object into BookMetadata.""" try: title = work.get("title") @@ -424,11 +437,11 @@ class OpenLibraryProvider(MetadataProvider): source_url=f"{OPENLIBRARY_BASE_URL}/works/{work_id}", ) - except Exception as e: - logger.debug(f"Failed to parse Open Library work: {e}") + except (TypeError, ValueError, AttributeError, KeyError) as e: + logger.debug("Failed to parse Open Library work: %s", e) return None - def _parse_edition(self, edition: dict, isbn: str) -> Optional[BookMetadata]: + def _parse_edition(self, edition: dict, isbn: str) -> BookMetadata | None: """Parse an edition object into BookMetadata (fallback for ISBN lookup).""" try: title = edition.get("title") @@ -461,7 +474,7 @@ class OpenLibraryProvider(MetadataProvider): publish_date = edition.get("publish_date", "") if publish_date: # Try to extract year from various formats - year_match = re.search(r'\b(19|20)\d{2}\b', publish_date) + year_match = re.search(r"\b(19|20)\d{2}\b", publish_date) if year_match: publish_year = int(year_match.group()) @@ -478,11 +491,11 @@ class OpenLibraryProvider(MetadataProvider): source_url=f"{OPENLIBRARY_BASE_URL}{key}" if key else None, ) - except Exception as e: - logger.debug(f"Failed to parse Open Library edition: {e}") + except (TypeError, ValueError, AttributeError, KeyError) as e: + logger.debug("Failed to parse Open Library edition: %s", e) return None - def _get_author_name(self, author_key: str) -> Optional[str]: + def _get_author_name(self, author_key: str) -> str | None: """Get author name from author key (e.g., '/authors/OL123A').""" _rate_limiter.wait_if_needed() @@ -496,13 +509,14 @@ class OpenLibraryProvider(MetadataProvider): author = response.json() return author.get("name") - except Exception: + except requests.RequestException, ValueError: # Don't log errors for author lookups - they're supplementary return None -def _test_openlibrary_connection() -> Dict[str, Any]: +def _test_openlibrary_connection() -> dict[str, Any]: """Test the Open Library API connection.""" + connection_result = {"success": False, "message": "Unexpected response from API"} try: provider = OpenLibraryProvider() # Simple API call to test connectivity @@ -515,15 +529,17 @@ def _test_openlibrary_connection() -> Dict[str, Any]: response.raise_for_status() data = response.json() if "docs" in data: - return {"success": True, "message": "Successfully connected to Open Library API"} - else: - return {"success": False, "message": "Unexpected response from API"} + connection_result = { + "success": True, + "message": "Successfully connected to Open Library API", + } except requests.Timeout: return {"success": False, "message": "Connection timed out"} except requests.RequestException as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} - except Exception as e: - return {"success": False, "message": f"Error: {str(e)}"} + return {"success": False, "message": f"Connection failed: {e!s}"} + except (TypeError, ValueError, AttributeError) as e: + return {"success": False, "message": f"Error: {e!s}"} + return connection_result # Open Library sort options for settings UI @@ -534,8 +550,10 @@ _OPENLIBRARY_SORT_OPTIONS = [ ] -@register_settings("openlibrary", "Open Library", icon="library", order=52, group="metadata_providers") -def openlibrary_settings(): +@register_settings( + "openlibrary", "Open Library", icon="library", order=52, group="metadata_providers" +) +def openlibrary_settings() -> list[SettingsField]: """Open Library metadata provider settings.""" return [ HeadingField( diff --git a/shelfmark/release_sources/__init__.py b/shelfmark/release_sources/__init__.py index c424ebbb..58c56a80 100644 --- a/shelfmark/release_sources/__init__.py +++ b/shelfmark/release_sources/__init__.py @@ -1,25 +1,28 @@ """Release source plugin system - base classes and registry.""" from abc import ABC, abstractmethod -from dataclasses import dataclass, field, asdict -from enum import Enum -from pathlib import Path -from threading import Event -from typing import List, Optional, Dict, Type, Callable, Literal, Any, TYPE_CHECKING +from dataclasses import dataclass, field +from enum import StrEnum +from typing import TYPE_CHECKING, Any, ClassVar, Literal if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + from threading import Event + + from shelfmark.core.models import DownloadTask from shelfmark.core.search_plan import ReleaseSearchPlan -from shelfmark.core.models import DownloadTask from shelfmark.metadata_providers import BookMetadata -class ReleaseProtocol(str, Enum): +class ReleaseProtocol(StrEnum): """Protocol for downloading a release.""" - HTTP = "http" # Direct HTTP download - TORRENT = "torrent" # BitTorrent - NZB = "nzb" # Usenet NZB - DCC = "dcc" # IRC DCC + + HTTP = "http" # Direct HTTP download + TORRENT = "torrent" # BitTorrent + NZB = "nzb" # Usenet NZB + DCC = "dcc" # IRC DCC class SourceUnavailableError(Exception): @@ -29,76 +32,82 @@ class SourceUnavailableError(Exception): @dataclass class BrowseRecord: """Source-native browse/search record used before normalization to Release.""" + id: str title: str source: str - preview: Optional[str] = None - author: Optional[str] = None - publisher: Optional[str] = None - year: Optional[str] = None - language: Optional[str] = None - content: Optional[str] = None - format: Optional[str] = None - size: Optional[str] = None - info: Optional[Dict[str, List[str]]] = None - description: Optional[str] = None - download_urls: List[str] = field(default_factory=list) - download_path: Optional[str] = None + preview: str | None = None + author: str | None = None + publisher: str | None = None + year: str | None = None + language: str | None = None + content: str | None = None + format: str | None = None + size: str | None = None + info: dict[str, list[str]] | None = None + description: str | None = None + download_urls: list[str] = field(default_factory=list) + download_path: str | None = None priority: int = 0 - progress: Optional[float] = None - status_message: Optional[str] = None - added_time: Optional[float] = None - source_url: Optional[str] = None + progress: float | None = None + status_message: str | None = None + added_time: float | None = None + source_url: str | None = None @dataclass class Release: """A downloadable release - all sources return this same structure.""" - source: str # "direct", "prowlarr", "irc", etc. - source_id: str # ID within that source + + source: str # "direct", "prowlarr", "irc", etc. + source_id: str # ID within that source title: str - format: Optional[str] = None - language: Optional[str] = None # ISO 639-1 code (e.g., "en", "de", "fr") - size: Optional[str] = None - size_bytes: Optional[int] = None - download_url: Optional[str] = None - info_url: Optional[str] = None # Link to release info page (e.g., tracker) - makes title clickable - protocol: Optional[ReleaseProtocol] = None - indexer: Optional[str] = None # Source name for display - seeders: Optional[int] = None # For torrents - peers: Optional[str] = None # For torrents: "seeders/leechers" display string - content_type: Optional[str] = None # "ebook" or "audiobook" - preserved from search - extra: Dict = field(default_factory=dict) # Source-specific metadata + format: str | None = None + language: str | None = None # ISO 639-1 code (e.g., "en", "de", "fr") + size: str | None = None + size_bytes: int | None = None + download_url: str | None = None + info_url: str | None = None # Link to release info page (e.g., tracker) - makes title clickable + protocol: ReleaseProtocol | None = None + indexer: str | None = None # Source name for display + seeders: int | None = None # For torrents + peers: str | None = None # For torrents: "seeders/leechers" display string + content_type: str | None = None # "ebook" or "audiobook" - preserved from search + extra: dict = field(default_factory=dict) # Source-specific metadata @dataclass class DownloadProgress: """DEPRECATED: Use progress_callback and status_callback instead.""" - status: str # "queued", "resolving", "downloading", "complete", "failed" - progress: float # 0-100 - status_message: Optional[str] = None - download_speed: Optional[int] = None - eta: Optional[int] = None - save_path: Optional[str] = None + + status: str # "queued", "resolving", "downloading", "complete", "failed" + progress: float # 0-100 + status_message: str | None = None + download_speed: int | None = None + eta: int | None = None + save_path: str | None = None # --- Column Schema for Plugin-Driven UI --- -class ColumnRenderType(str, Enum): + +class ColumnRenderType(StrEnum): """How the frontend should render the column value.""" - TEXT = "text" # Plain text - BADGE = "badge" # Colored badge (format, language) - TAGS = "tags" # List of colored badges - SIZE = "size" # File size formatting - NUMBER = "number" # Numeric value - PEERS = "peers" # Peers display: "S/L" with color based on seeder count + + TEXT = "text" # Plain text + BADGE = "badge" # Colored badge (format, language) + TAGS = "tags" # List of colored badges + SIZE = "size" # File size formatting + NUMBER = "number" # Numeric value + PEERS = "peers" # Peers display: "S/L" with color based on seeder count INDEXER_PROTOCOL = "indexer_protocol" # Text + colored dot for torrent/usenet - FLAG_ICON = "flag_icon" # Icon with tooltip (VIP, freeleech, etc.) + FLAG_ICON = "flag_icon" # Icon with tooltip (VIP, freeleech, etc.) FORMAT_CONTENT_TYPE = "format_content_type" # Content type icon + format badge -class ColumnAlign(str, Enum): +class ColumnAlign(StrEnum): """Column alignment options.""" + LEFT = "left" CENTER = "center" RIGHT = "right" @@ -107,74 +116,89 @@ class ColumnAlign(str, Enum): @dataclass class ColumnColorHint: """Color hint for badge-type columns.""" - type: Literal["map", "static"] # "map" uses frontend colorMaps, "static" is fixed class - value: str # Map name ("format", "language") or Tailwind class + + type: Literal["map", "static"] # "map" uses frontend colorMaps, "static" is fixed class + value: str # Map name ("format", "language") or Tailwind class @dataclass class ColumnSchema: """Definition for a single column in the release list.""" - key: str # Data path (e.g., "format", "extra.language") - label: str # Accessibility label + + key: str # Data path (e.g., "format", "extra.language") + label: str # Accessibility label render_type: ColumnRenderType = ColumnRenderType.TEXT align: ColumnAlign = ColumnAlign.LEFT - width: str = "auto" # CSS width (e.g., "80px", "minmax(0,2fr)") - hide_mobile: bool = False # Hide on small screens - color_hint: Optional[ColumnColorHint] = None # For BADGE render type - fallback: str = "-" # Value to show when data is missing - uppercase: bool = False # Force uppercase display - sortable: bool = False # Show in sort dropdown (opt-in) - sort_key: Optional[str] = None # Field to sort by (defaults to `key` if None) + width: str = "auto" # CSS width (e.g., "80px", "minmax(0,2fr)") + hide_mobile: bool = False # Hide on small screens + color_hint: ColumnColorHint | None = None # For BADGE render type + fallback: str = "-" # Value to show when data is missing + uppercase: bool = False # Force uppercase display + sortable: bool = False # Show in sort dropdown (opt-in) + sort_key: str | None = None # Field to sort by (defaults to `key` if None) -class LeadingCellType(str, Enum): +class LeadingCellType(StrEnum): """Type of leading cell to display in release rows.""" + THUMBNAIL = "thumbnail" # Show book cover image - BADGE = "badge" # Show colored badge (e.g., "Torrent", "Usenet") - NONE = "none" # No leading cell + BADGE = "badge" # Show colored badge (e.g., "Torrent", "Usenet") + NONE = "none" # No leading cell @dataclass class LeadingCellConfig: """Configuration for the leading cell in release rows.""" + type: LeadingCellType = LeadingCellType.THUMBNAIL - key: Optional[str] = None # Field path for data (e.g., "extra.preview" or "extra.download_type") - color_hint: Optional[ColumnColorHint] = None # For badge type - maps values to colors - uppercase: bool = False # Force uppercase for badge text + key: str | None = None # Field path for data (e.g., "extra.preview" or "extra.download_type") + color_hint: ColumnColorHint | None = None # For badge type - maps values to colors + uppercase: bool = False # Force uppercase for badge text @dataclass class SortOption: """A sort option that appears in the sort dropdown without being tied to a column.""" - label: str # Display label in the sort dropdown - sort_key: str # Field to sort by on the Release object + + label: str # Display label in the sort dropdown + sort_key: str # Field to sort by on the Release object @dataclass class SourceActionButton: """Action button configuration for a release source.""" - label: str # Button text (e.g., "Refresh search") - action: str = "expand" # Action type: "expand" triggers expand_search + + label: str # Button text (e.g., "Refresh search") + action: str = "expand" # Action type: "expand" triggers expand_search @dataclass class ReleaseColumnConfig: """Complete column configuration for a release source.""" - columns: List[ColumnSchema] + + columns: list[ColumnSchema] grid_template: str = "minmax(0,2fr) 60px 80px 80px" # CSS grid-template-columns - leading_cell: Optional[LeadingCellConfig] = None # Defaults to thumbnail mode if None - online_servers: Optional[List[str]] = None # For IRC: list of currently online server nicks - available_indexers: Optional[List[str]] = None # For Prowlarr: list of all enabled indexer names - default_indexers: Optional[List[str]] = None # For Prowlarr: indexers selected in settings (pre-selected in filter) - cache_ttl_seconds: Optional[int] = None # How long to cache results (default: 5 min) - supported_filters: Optional[List[str]] = None # Which filters this source supports: ["format", "language", "indexer"] - extra_sort_options: Optional[List[SortOption]] = None # Additional sort options not tied to a column - action_button: Optional[SourceActionButton] = None # Custom action button (replaces default expand search) + leading_cell: LeadingCellConfig | None = None # Defaults to thumbnail mode if None + online_servers: list[str] | None = None # For IRC: list of currently online server nicks + available_indexers: list[str] | None = None # For Prowlarr: list of all enabled indexer names + default_indexers: list[str] | None = ( + None # For Prowlarr: indexers selected in settings (pre-selected in filter) + ) + cache_ttl_seconds: int | None = None # How long to cache results (default: 5 min) + supported_filters: list[str] | None = ( + None # Which filters this source supports: ["format", "language", "indexer"] + ) + extra_sort_options: list[SortOption] | None = ( + None # Additional sort options not tied to a column + ) + action_button: SourceActionButton | None = ( + None # Custom action button (replaces default expand search) + ) -def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]: +def serialize_column_config(config: ReleaseColumnConfig) -> dict[str, Any]: """Serialize column configuration for API response.""" - result: Dict[str, Any] = { + result: dict[str, Any] = { "columns": [ { "key": col.key, @@ -185,8 +209,10 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]: "hide_mobile": col.hide_mobile, "color_hint": { "type": col.color_hint.type, - "value": col.color_hint.value - } if col.color_hint else None, + "value": col.color_hint.value, + } + if col.color_hint + else None, "fallback": col.fallback, "uppercase": col.uppercase, "sortable": col.sortable, @@ -204,8 +230,10 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]: "key": config.leading_cell.key, "color_hint": { "type": config.leading_cell.color_hint.type, - "value": config.leading_cell.color_hint.value - } if config.leading_cell.color_hint else None, + "value": config.leading_cell.color_hint.value, + } + if config.leading_cell.color_hint + else None, "uppercase": config.leading_cell.uppercase, } @@ -232,8 +260,7 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]: # Include extra sort options (sort entries not tied to a column) if config.extra_sort_options: result["extra_sort_options"] = [ - {"label": opt.label, "sort_key": opt.sort_key} - for opt in config.extra_sort_options + {"label": opt.label, "sort_key": opt.sort_key} for opt in config.extra_sort_options ] # Include action button if specified (replaces default expand search) @@ -286,26 +313,29 @@ def _default_column_config() -> ReleaseColumnConfig: class ReleaseSource(ABC): """Interface for searching a release source.""" - name: str # "direct", "prowlarr" - display_name: str # "Direct Download", "Prowlarr" - supported_content_types: List[str] = ["ebook", "audiobook"] # Content types this source supports - can_be_default: bool = True # Whether this source can be selected as default in settings + + name: str # "direct", "prowlarr" + display_name: str # "Direct Download", "Prowlarr" + supported_content_types: ClassVar[list[str]] = [ + "ebook", + "audiobook", + ] # Content types this source supports + can_be_default: bool = True # Whether this source can be selected as default in settings @abstractmethod def search( self, book: BookMetadata, - plan: "ReleaseSearchPlan", + plan: ReleaseSearchPlan, + *, expand_search: bool = False, - content_type: str = "ebook" - ) -> List[Release]: + content_type: str = "ebook", + ) -> list[Release]: """Search for releases of a book.""" - pass @abstractmethod def is_available(self) -> bool: """Check if this source is configured and reachable.""" - pass def get_column_config(self) -> ReleaseColumnConfig: """Get column configuration for release list UI. Override for custom columns.""" @@ -316,15 +346,16 @@ class ReleaseSource(ABC): record_id: str, *, fetch_download_count: bool = True, - ) -> Optional[BrowseRecord]: + ) -> BrowseRecord | None: """Resolve a source-native record for browse flows.""" - raise NotImplementedError(f"{self.display_name} does not support record lookup") + msg = f"{self.display_name} does not support record lookup" + raise NotImplementedError(msg) def search_results_are_releases(self) -> bool: """Whether source-native browse results already represent concrete releases.""" return False - def get_destination_override(self, task: DownloadTask) -> Optional[Path]: + def get_destination_override(self, task: DownloadTask) -> Path | None: """Return a source-specific destination override for a queued download.""" return None @@ -346,12 +377,11 @@ class DownloadHandler(ABC): task: DownloadTask, cancel_flag: Event, progress_callback: Callable[[float], None], - status_callback: Callable[[str, Optional[str]], None] - ) -> Optional[str]: + status_callback: Callable[[str, str | None], None], + ) -> str | None: """Execute download and return a path to the downloaded payload.""" - pass - def post_process_cleanup(self, task: DownloadTask, success: bool) -> None: + def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None: """Optional hook called after orchestrator post-processing. This is primarily used for external download clients, where the handler may need @@ -362,58 +392,71 @@ class DownloadHandler(ABC): @abstractmethod def cancel(self, task_id: str) -> bool: """Cancel an in-progress download.""" - pass # --- Registry --- -_SOURCES: Dict[str, Type[ReleaseSource]] = {} -_HANDLERS: Dict[str, Type[DownloadHandler]] = {} +_SOURCES: dict[str, type[ReleaseSource]] = {} +_HANDLERS: dict[str, type[DownloadHandler]] = {} -def register_source(name: str): +def register_source( + name: str, +) -> Callable[[type[ReleaseSource]], type[ReleaseSource]]: """Decorator to register a release source.""" - def decorator(cls): + + def decorator(cls: type[ReleaseSource]) -> type[ReleaseSource]: _SOURCES[name] = cls return cls + return decorator -def register_handler(name: str): +def register_handler( + name: str, +) -> Callable[[type[DownloadHandler]], type[DownloadHandler]]: """Decorator to register a download handler.""" - def decorator(cls): + + def decorator(cls: type[DownloadHandler]) -> type[DownloadHandler]: _HANDLERS[name] = cls return cls + return decorator def get_source(name: str) -> ReleaseSource: """Get a release source instance by name.""" if name not in _SOURCES: - raise ValueError(f"Unknown release source: {name}") + msg = f"Unknown release source: {name}" + raise ValueError(msg) return _SOURCES[name]() def get_handler(name: str) -> DownloadHandler: """Get a download handler instance by name.""" if name not in _HANDLERS: - raise ValueError(f"Unknown download handler: {name}") + msg = f"Unknown download handler: {name}" + raise ValueError(msg) return _HANDLERS[name]() -def list_available_sources() -> List[dict]: +def list_available_sources() -> list[dict]: """List all registered sources with their availability status.""" result = [] for name, src_class in _SOURCES.items(): instance = src_class() - result.append({ - "name": name, - "display_name": instance.display_name, - "enabled": instance.is_available(), - "supported_content_types": getattr(instance, 'supported_content_types', ["ebook", "audiobook"]), - "browse_results_are_releases": instance.search_results_are_releases(), - "can_be_default": getattr(instance, 'can_be_default', True), - }) + result.append( + { + "name": name, + "display_name": instance.display_name, + "enabled": instance.is_available(), + "supported_content_types": getattr( + instance, "supported_content_types", ["ebook", "audiobook"] + ), + "browse_results_are_releases": instance.search_results_are_releases(), + "can_be_default": getattr(instance, "can_be_default", True), + } + ) return result @@ -421,14 +464,14 @@ def get_source_display_name(name: str) -> str: """Get display name for a source by its identifier.""" if name in _SOURCES: return _SOURCES[name]().display_name - return name.replace('_', ' ').title() + return name.replace("_", " ").title() def browse_record_to_book_metadata( record: BrowseRecord, *, - title_override: Optional[str] = None, - author_override: Optional[str] = None, + title_override: str | None = None, + author_override: str | None = None, ) -> BookMetadata: """Convert a source-native browse record into generic book metadata.""" resolved_title = title_override or str(record.title or "").strip() or "Unknown title" @@ -469,7 +512,7 @@ def source_results_are_releases(name: str) -> bool: # Import source implementations to trigger registration # These must be imported AFTER the base classes and registry are defined -from shelfmark.release_sources import direct_download # noqa: F401, E402 -from shelfmark.release_sources import prowlarr # noqa: F401, E402 -from shelfmark.release_sources import irc # noqa: F401, E402 -from shelfmark.release_sources import audiobookbay # noqa: F401, E402 +from shelfmark.release_sources import audiobookbay as audiobookbay +from shelfmark.release_sources import direct_download as direct_download +from shelfmark.release_sources import irc as irc +from shelfmark.release_sources import prowlarr as prowlarr diff --git a/shelfmark/release_sources/audiobookbay/__init__.py b/shelfmark/release_sources/audiobookbay/__init__.py index 748caf9c..0e9bf732 100644 --- a/shelfmark/release_sources/audiobookbay/__init__.py +++ b/shelfmark/release_sources/audiobookbay/__init__.py @@ -1,6 +1,6 @@ """AudiobookBay release source - web scraping for audiobook torrents.""" # Import to trigger registration -from shelfmark.release_sources.audiobookbay import source # noqa: F401, E402 -from shelfmark.release_sources.audiobookbay import handler # noqa: F401, E402 -from shelfmark.release_sources.audiobookbay import settings # noqa: F401, E402 +from shelfmark.release_sources.audiobookbay import handler as handler +from shelfmark.release_sources.audiobookbay import settings as settings +from shelfmark.release_sources.audiobookbay import source as source diff --git a/shelfmark/release_sources/audiobookbay/handler.py b/shelfmark/release_sources/audiobookbay/handler.py index ac102f91..20eb64ff 100644 --- a/shelfmark/release_sources/audiobookbay/handler.py +++ b/shelfmark/release_sources/audiobookbay/handler.py @@ -1,17 +1,28 @@ """AudiobookBay download handler - resolves magnet links and uses shared client lifecycle.""" -from typing import Callable, Optional +from typing import TYPE_CHECKING from urllib.parse import urlparse from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask -from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients -from shelfmark.download.clients.base_handler import DownloadRequest, ExternalClientHandler +from shelfmark.download.clients import ( + DownloadClient, + get_client, + list_configured_clients, +) +from shelfmark.download.clients.base_handler import ( + DownloadRequest, + ExternalClientHandler, +) from shelfmark.release_sources import register_handler from shelfmark.release_sources.audiobookbay import scraper from shelfmark.release_sources.audiobookbay.utils import normalize_hostname +if TYPE_CHECKING: + from collections.abc import Callable + + from shelfmark.core.models import DownloadTask + logger = setup_logger(__name__) @@ -20,7 +31,7 @@ class AudiobookBayHandler(ExternalClientHandler): """Handler for AudiobookBay downloads via configured torrent client.""" @staticmethod - def _resolve_detail_url(task: DownloadTask) -> Optional[str]: + def _resolve_detail_url(task: DownloadTask) -> str | None: """Resolve ABB detail URL from queued task metadata.""" source_url = (task.source_url or "").strip() if source_url: @@ -32,7 +43,7 @@ class AudiobookBayHandler(ExternalClientHandler): return task_id return None - def _get_client(self, protocol: str) -> Optional[DownloadClient]: + def _get_client(self, protocol: str) -> DownloadClient | None: """Compatibility shim so module-level patching still works in tests.""" return get_client(protocol) @@ -43,13 +54,13 @@ class AudiobookBayHandler(ExternalClientHandler): def _resolve_download( self, task: DownloadTask, - status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[DownloadRequest]: + status_callback: Callable[[str, str | None], None], + ) -> DownloadRequest | None: """Resolve ABB detail page into a magnet-link download request.""" detail_url = self._resolve_detail_url(task) if not detail_url: status_callback("error", "Missing AudiobookBay details URL") - logger.warning(f"Missing details URL for AudiobookBay task: {task.task_id}") + logger.warning("Missing details URL for AudiobookBay task: %s", task.task_id) return None hostname = normalize_hostname(config.get("ABB_HOSTNAME", "")) @@ -63,7 +74,7 @@ class AudiobookBayHandler(ExternalClientHandler): status_callback("error", "Failed to extract magnet link from detail page") return None - logger.info(f"Extracted magnet link for task {task.task_id}") + logger.info("Extracted magnet link for task %s", task.task_id) return DownloadRequest( url=magnet_link, @@ -79,5 +90,5 @@ class AudiobookBayHandler(ExternalClientHandler): been sent to the torrent client we do not remove it client-side. Users must cancel/remove it in their torrent client UI. """ - logger.debug(f"Cancel requested for AudiobookBay task: {task_id}") + logger.debug("Cancel requested for AudiobookBay task: %s", task_id) return False diff --git a/shelfmark/release_sources/audiobookbay/scraper.py b/shelfmark/release_sources/audiobookbay/scraper.py index 6c8573ab..0bdd26d2 100644 --- a/shelfmark/release_sources/audiobookbay/scraper.py +++ b/shelfmark/release_sources/audiobookbay/scraper.py @@ -2,7 +2,6 @@ import re import time -from typing import List, Optional, Dict from urllib.parse import quote import requests @@ -66,11 +65,15 @@ def _is_homepage_redirect(final_url: str, hostname: str) -> bool: return normalized_final in {normalized_home, f"{normalized_home}/"} -def _encode_search_query(query: str, exact_phrase: bool) -> str: +def _encode_search_query(query: str, *, exact_phrase: bool) -> str: """Encode search query using ABB's space-plus style and optional exact phrase wrapping.""" search_query = query.strip() - if exact_phrase and search_query and not (search_query.startswith('"') and search_query.endswith('"')): - search_query = f"\"{search_query}\"" + if ( + exact_phrase + and search_query + and not (search_query.startswith('"') and search_query.endswith('"')) + ): + search_query = f'"{search_query}"' # Keep ABB-friendly encoding style (spaces as '+') while percent-encoding quotes. return search_query.replace('"', "%22").replace(" ", "+") @@ -110,18 +113,20 @@ def search_audiobookbay( query: str, max_pages: int = 1, hostname: str = "audiobookbay.lu", + *, exact_phrase: bool = False, -) -> List[Dict[str, str]]: +) -> list[dict[str, str]]: """Search AudiobookBay for audiobooks matching the query. - + Args: query: Search query string max_pages: Maximum number of pages to fetch hostname: AudiobookBay hostname (e.g., "audiobookbay.lu") exact_phrase: Wrap query in quotes for exact phrase matching - + Returns: List of dicts with keys: title, link, cover, language, format, bitrate, size, posted_date + """ results = [] rate_limit_delay = config.get("ABB_RATE_LIMIT_DELAY", 1.0) @@ -130,12 +135,12 @@ def search_audiobookbay( # Bootstrap ABB session cookie (PHPSESSID). ABB increasingly serves reliable # search/detail pages only after session initialization, similar to browsers. _bootstrap_abb_session(hostname, session, SEARCH_PAGE_RETRY_ATTEMPTS) - + # Iterate through pages for page in range(1, max_pages + 1): # Construct URL - use + for spaces (matching audiobookbay-automated implementation) # This avoids aggressive encoding that PHP-based sites may reject. - query_encoded = _encode_search_query(query, exact_phrase) + query_encoded = _encode_search_query(query, exact_phrase=exact_phrase) # ABB search expects the legacy category query parameter. primary_url = _build_search_url( hostname, @@ -143,7 +148,7 @@ def search_audiobookbay( query_encoded, include_legacy_category=True, ) - + try: # Reuse shared HTTP fetch logic (without bypasser) page_html, final_url = downloader.html_get_page( @@ -183,84 +188,93 @@ def search_audiobookbay( ) if not page_html: - logger.warning(f"Failed to fetch page {page}") + logger.warning("Failed to fetch page %s", page) break - + # Check if we were redirected to the homepage (search was rejected/blocked) if was_home_redirect: # Search was redirected to homepage - this means the search failed # This can happen due to geo-blocking, rate limiting, or invalid query format if page == 1: - logger.warning(f"Search query '{query}' was redirected to homepage - search may be blocked or invalid") + logger.warning( + "Search query '%s' was redirected to homepage - search may be blocked or invalid", + query, + ) break - + # Parse HTML - soup = BeautifulSoup(page_html, 'html.parser') - + soup = BeautifulSoup(page_html, "html.parser") + # Extract book entries - posts = soup.select('.post') + posts = soup.select(".post") if not posts: # No more results break - + for post in posts: try: # Extract title - title_elem = post.select_one('.postTitle > h2 > a') + title_elem = post.select_one(".postTitle > h2 > a") if not title_elem: continue - + title = title_elem.text.strip() - + # Extract link (relative, needs hostname prefix) - href = title_elem.get('href', '') + href = title_elem.get("href", "") if not href: continue - + link = _normalize_result_url(href, hostname) if not link: continue - + # Extract cover image (try .postContent .center img first, then fallback to any img) cover = None - cover_elem = post.select_one('.postContent .center img') or post.select_one('img') + cover_elem = post.select_one(".postContent .center img") or post.select_one( + "img" + ) if cover_elem: - cover = _normalize_result_url(cover_elem.get('src', ''), hostname) or None - + cover = _normalize_result_url(cover_elem.get("src", ""), hostname) or None + # Extract language from .postInfo language = None - post_info = post.select_one('.postInfo') + post_info = post.select_one(".postInfo") if post_info: - info_text = post_info.get_text(separator=' ', strip=True).replace('\xa0', ' ') + info_text = post_info.get_text(separator=" ", strip=True).replace( + "\xa0", " " + ) lang_match = LANGUAGE_PATTERN.search(info_text) if lang_match: language = lang_match.group(1).strip() - + # Extract format, bitrate, size, and posted date from .postContent posted_date = None format_type = None bitrate = None size_str = None - - post_content = post.select_one('.postContent') + + post_content = post.select_one(".postContent") if post_content: - content_text = post_content.get_text(separator=' ', strip=True).replace('\xa0', ' ') - + content_text = post_content.get_text(separator=" ", strip=True).replace( + "\xa0", " " + ) + # Extract posted date posted_match = POSTED_PATTERN.search(content_text) if posted_match: posted_date = posted_match.group(1).strip() - + # Extract format (e.g., "M4B", "MP3") format_match = FORMAT_PATTERN.search(content_text) if format_match: format_type = format_match.group(1).strip() - + # Extract bitrate (e.g., "256 Kbps") bitrate_match = BITRATE_PATTERN.search(content_text) if bitrate_match: bitrate = bitrate_match.group(1).strip() - + # Extract file size (e.g., "11.68 GBs" -> normalized to "11.68 GB") size_match = SIZE_PATTERN.search(content_text) if size_match: @@ -270,44 +284,44 @@ def search_audiobookbay( size_unit = size_unit[:-1] size_unit = size_unit.upper() size_str = f"{size_value} {size_unit}" - - results.append({ - 'title': title, - 'link': link, - 'cover': cover or None, - 'language': language, - 'format': format_type, - 'bitrate': bitrate, - 'size': size_str, - 'posted_date': posted_date, - }) - except Exception as e: - logger.debug(f"Skipping post due to error: {e}") + + results.append( + { + "title": title, + "link": link, + "cover": cover or None, + "language": language, + "format": format_type, + "bitrate": bitrate, + "size": size_str, + "posted_date": posted_date, + } + ) + except (TypeError, ValueError, AttributeError, IndexError, KeyError) as e: + logger.debug("Skipping post due to error: %s", e) continue - + # Rate limiting delay between pages if page < max_pages and rate_limit_delay > 0: time.sleep(rate_limit_delay) - except Exception as e: - logger.error(f"Unexpected error on page {page}: {e}") + except Exception: + logger.exception("Unexpected error on page %s", page) break - - logger.info(f"Found {len(results)} results for query '{query}'") + + logger.info("Found %s results for query '%s'", len(results), query) return results -def extract_magnet_link( - details_url: str, - hostname: str = "audiobookbay.lu" -) -> Optional[str]: +def extract_magnet_link(details_url: str, hostname: str = "audiobookbay.lu") -> str | None: """Extract info hash and trackers from book detail page, then construct magnet link. - + Args: details_url: URL of the book's detail page hostname: AudiobookBay hostname (for logging) - + Returns: Magnet link, or None if extraction fails + """ try: session = requests.Session() @@ -334,65 +348,64 @@ def extract_magnet_link( success_delay=0, session=session, ) - + if not detail_html: logger.warning("Failed to fetch details page") return None - - soup = BeautifulSoup(detail_html, 'html.parser') - + + soup = BeautifulSoup(detail_html, "html.parser") + # 1. Extract Info Hash # Look for Info Hash and get next sibling value info_hash = None - info_hash_rows = soup.find_all('td') + info_hash_rows = soup.find_all("td") for td in info_hash_rows: - if td.text.strip().lower() == 'info hash': - next_td = td.find_next_sibling('td') + if td.text.strip().lower() == "info hash": + next_td = td.find_next_sibling("td") if next_td: info_hash = next_td.text.strip() break - + # Alternative: search for text containing "Info Hash" and get next element if not info_hash: for elem in soup.find_all(string=INFO_HASH_LABEL_PATTERN): parent = elem.parent - if parent and parent.name == 'td': - next_td = parent.find_next_sibling('td') + if parent and parent.name == "td": + next_td = parent.find_next_sibling("td") if next_td: info_hash = next_td.text.strip() break - + if not info_hash: logger.warning("Info Hash not found on the page.") return None - + # Clean up info hash (remove whitespace, ensure uppercase) - info_hash = re.sub(r'\s+', '', info_hash).upper() - + info_hash = re.sub(r"\s+", "", info_hash).upper() + # 2. Extract Trackers # Find all containing udp:// or http:// trackers = [] - for td in soup.find_all('td'): + for td in soup.find_all("td"): text = td.text.strip() - if text.startswith(('udp://', 'http://', 'https://')): + if text.startswith(("udp://", "http://", "https://")): trackers.append(text) - + # 3. Use default trackers if none found if not trackers: logger.debug("No trackers found on the page. Using default trackers.") trackers = DEFAULT_TRACKERS - + # 4. Construct Magnet Link # Format: magnet:?xt=urn:btih:{INFO_HASH}&tr={TRACKER1}&tr={TRACKER2}... - tracker_params = "&".join( - f"tr={quote(tracker)}" - for tracker in trackers - ) + tracker_params = "&".join(f"tr={quote(tracker)}" for tracker in trackers) magnet_link = f"magnet:?xt=urn:btih:{info_hash}&{tracker_params}" - - logger.debug(f"Generated Magnet Link: {magnet_link[:100]}...") - return magnet_link - - except Exception as e: - logger.error(f"Failed to extract magnet link: {e}") + + logger.debug("Generated Magnet Link: %s...", magnet_link[:100]) + + except Exception: + logger.exception("Failed to extract magnet link") return None + + else: + return magnet_link diff --git a/shelfmark/release_sources/audiobookbay/settings.py b/shelfmark/release_sources/audiobookbay/settings.py index 10098c0a..55f5297b 100644 --- a/shelfmark/release_sources/audiobookbay/settings.py +++ b/shelfmark/release_sources/audiobookbay/settings.py @@ -1,17 +1,18 @@ """AudiobookBay settings registration.""" from shelfmark.core.settings_registry import ( - register_settings, CheckboxField, - TextField, NumberField, + SettingsField, + TextField, + register_settings, ) - # ==================== Register Settings ==================== + @register_settings("audiobookbay_config", "AudiobookBay", icon="download", order=45) -def audiobookbay_config_settings(): +def audiobookbay_config_settings() -> list[SettingsField]: """AudiobookBay configuration settings.""" return [ CheckboxField( @@ -24,7 +25,6 @@ def audiobookbay_config_settings(): key="ABB_HOSTNAME", label="Hostname", description="AudiobookBay domain (e.g., audiobookbay.lu, audiobookbay.is). Required to enable searches.", - placeholder="", default="", required=True, show_when={"field": "ABB_ENABLED", "value": True}, @@ -42,7 +42,6 @@ def audiobookbay_config_settings(): key="ABB_EXACT_PHRASE", label="Prefer Exact-Phrase Search", description="Wrap generated queries in quotes for stricter matching. If no results are found, Shelfmark retries without quotes.", - default=False, show_when={"field": "ABB_ENABLED", "value": True}, ), NumberField( diff --git a/shelfmark/release_sources/audiobookbay/source.py b/shelfmark/release_sources/audiobookbay/source.py index 751c1382..a78d0395 100644 --- a/shelfmark/release_sources/audiobookbay/source.py +++ b/shelfmark/release_sources/audiobookbay/source.py @@ -2,24 +2,24 @@ import hashlib import re -from typing import List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar if TYPE_CHECKING: from shelfmark.core.search_plan import ReleaseSearchPlan + from shelfmark.metadata_providers import BookMetadata from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.metadata_providers import BookMetadata from shelfmark.release_sources import ( + ColumnAlign, + ColumnColorHint, + ColumnRenderType, + ColumnSchema, Release, + ReleaseColumnConfig, ReleaseProtocol, ReleaseSource, register_source, - ReleaseColumnConfig, - ColumnSchema, - ColumnRenderType, - ColumnAlign, - ColumnColorHint, ) from shelfmark.release_sources.audiobookbay import scraper from shelfmark.release_sources.audiobookbay.utils import normalize_hostname, parse_size @@ -65,7 +65,7 @@ LANGUAGE_MAP = { } -def _split_title_and_author(raw_title: str) -> tuple[str, Optional[str]]: +def _split_title_and_author(raw_title: str) -> tuple[str, str | None]: """Split titles in the form 'Title - Author' into title and author. Args: @@ -73,6 +73,7 @@ def _split_title_and_author(raw_title: str) -> tuple[str, Optional[str]]: Returns: (title, author) where author is None if split is unavailable. + """ if not raw_title: return "", None @@ -90,23 +91,24 @@ def _split_title_and_author(raw_title: str) -> tuple[str, Optional[str]]: return title_part, author_part -def _map_language(language: str) -> Optional[str]: +def _map_language(language: str) -> str | None: """Map language name to ISO 639-1 code. - + Args: language: Language name (e.g., "English") - + Returns: ISO 639-1 code (e.g., "en"), or original string if no mapping found, or None if input is empty + """ if not language: return None - + lang_lower = language.lower().strip() return LANGUAGE_MAP.get(lang_lower, lang_lower) -def _parse_bitrate_to_kbps(bitrate: Optional[str]) -> Optional[int]: +def _parse_bitrate_to_kbps(bitrate: str | None) -> int | None: """Parse bitrate string to an integer Kbps value. Args: @@ -114,6 +116,7 @@ def _parse_bitrate_to_kbps(bitrate: Optional[str]) -> Optional[int]: Returns: Bitrate value in Kbps as integer, or None if parsing fails. + """ if not bitrate: return None @@ -136,40 +139,42 @@ def _generate_source_id(detail_url: str) -> str: @register_source("audiobookbay") class AudiobookBaySource(ReleaseSource): """Release source for AudiobookBay audiobook torrents.""" - + name = "audiobookbay" display_name = "AudiobookBay" - supported_content_types = ["audiobook"] # ONLY audiobooks - + supported_content_types: ClassVar[list[str]] = ["audiobook"] # ONLY audiobooks + def search( self, book: BookMetadata, - plan: "ReleaseSearchPlan", + plan: ReleaseSearchPlan, + *, expand_search: bool = False, - content_type: str = "ebook" - ) -> List[Release]: + content_type: str = "ebook", + ) -> list[Release]: """Search AudiobookBay for audiobook releases. - + Args: book: Book metadata plan: Search plan with query variants expand_search: Ignored (always searches) content_type: Must be "audiobook" for this source - + Returns: List of Release objects + """ # Only search for audiobooks if content_type != "audiobook": return [] - + hostname = normalize_hostname(config.get("ABB_HOSTNAME", "")) if not hostname: logger.debug("AudiobookBay hostname is not configured") return [] max_pages = config.get("ABB_PAGE_LIMIT", 1) exact_phrase = bool(config.get("ABB_EXACT_PHRASE", False)) - + # Build search query candidates from plan. query_candidates: list[str] = [] if plan.manual_query: @@ -204,11 +209,11 @@ class AudiobookBaySource(ReleaseSource): results = [] query_lower = deduped_queries[0].lower() - + try: for index, query in enumerate(deduped_queries): query_lower = query.lower() - logger.info(f"Searching AudiobookBay for: {query_lower}") + logger.info("Searching AudiobookBay for: %s", query_lower) # Search AudiobookBay results = scraper.search_audiobookbay( @@ -220,7 +225,9 @@ class AudiobookBaySource(ReleaseSource): # For auto-generated queries, fallback to broad matching if exact phrase returns nothing. if exact_phrase and not results and not plan.manual_query: - logger.info("No exact phrase results, retrying AudiobookBay search without quotes") + logger.info( + "No exact phrase results, retrying AudiobookBay search without quotes" + ) results = scraper.search_audiobookbay( query=query_lower, max_pages=max_pages, @@ -237,36 +244,35 @@ class AudiobookBaySource(ReleaseSource): query_lower, deduped_queries[index + 1].lower(), ) - + # Extract query words for relevance checking - query_words = set(word.lower() for word in query_lower.split() if len(word) > 2) - + query_words = {word.lower() for word in query_lower.split() if len(word) > 2} + releases = [] for result in results: try: - raw_title = result['title'] + raw_title = result["title"] title, author = _split_title_and_author(raw_title) title_for_filter = raw_title.lower() - + # Basic relevance check: ensure title contains at least one query word # This filters out homepage "Latest" feed items that may leak through - if query_words: - if not any(word in title_for_filter for word in query_words): - logger.debug(f"Filtering out irrelevant result: {title}") - continue - + if query_words and not any(word in title_for_filter for word in query_words): + logger.debug("Filtering out irrelevant result: %s", title) + continue + # Generate unique source ID - source_id = _generate_source_id(result['link']) - + source_id = _generate_source_id(result["link"]) + # Extract and parse metadata - format_type = result.get('format') - size_str = result.get('size') + format_type = result.get("format") + size_str = result.get("size") size_bytes = parse_size(size_str) if size_str else None - language_raw = result.get('language') + language_raw = result.get("language") language_code = _map_language(language_raw) if language_raw else None - bitrate = result.get('bitrate') + bitrate = result.get("bitrate") bitrate_kbps = _parse_bitrate_to_kbps(bitrate) - + # Create Release object release = Release( source="audiobookbay", @@ -276,43 +282,46 @@ class AudiobookBaySource(ReleaseSource): language=language_code, size=size_str, size_bytes=size_bytes, - download_url=result['link'], # Detail page URL (used by handler) - info_url=result['link'], # Make title clickable + download_url=result["link"], # Detail page URL (used by handler) + info_url=result["link"], # Make title clickable protocol=ReleaseProtocol.TORRENT, indexer="AudiobookBay", seeders=None, # Not available on search page peers=None, content_type="audiobook", extra={ - "preview": result.get('cover'), - "detail_url": result['link'], + "preview": result.get("cover"), + "detail_url": result["link"], "bitrate": bitrate, "bitrate_value": bitrate_kbps, - "posted_date": result.get('posted_date'), + "posted_date": result.get("posted_date"), "title_raw": raw_title, "language_raw": language_raw, # Keep original for reference "author": author, # Parsed author from title pattern - } + }, ) releases.append(release) - except Exception as e: - logger.warning(f"Failed to create release from result: {e}") + except (AttributeError, KeyError, TypeError, ValueError) as e: + logger.warning("Failed to create release from result: %s", e) continue - - logger.info(f"Found {len(releases)} releases from AudiobookBay") - return releases - - except Exception as e: - logger.error(f"AudiobookBay search error: {e}") + + except Exception: + logger.exception("AudiobookBay search error") return [] - + + else: + logger.info("Found %s releases from AudiobookBay", len(releases)) + return releases + def is_available(self) -> bool: """Check if AudiobookBay source is enabled and configured.""" - return config.get("ABB_ENABLED", False) is True and bool(normalize_hostname(config.get("ABB_HOSTNAME", ""))) - + return config.get("ABB_ENABLED", False) is True and bool( + normalize_hostname(config.get("ABB_HOSTNAME", "")) + ) + def get_column_config(self) -> ReleaseColumnConfig: """Get column configuration for AudiobookBay releases. - + Shows title, language, format, bitrate, and size columns. No seeders/peers since ABB doesn't show this on search page. """ diff --git a/shelfmark/release_sources/audiobookbay/utils.py b/shelfmark/release_sources/audiobookbay/utils.py index e174d361..58719fc4 100644 --- a/shelfmark/release_sources/audiobookbay/utils.py +++ b/shelfmark/release_sources/audiobookbay/utils.py @@ -1,10 +1,9 @@ """Utility functions for AudiobookBay integration.""" import re -from typing import Optional -def normalize_hostname(raw: Optional[str]) -> str: +def normalize_hostname(raw: str | None) -> str: """Normalize a user-supplied hostname for URL construction. Strips whitespace, scheme prefixes, trailing slashes, and paths so that @@ -17,14 +16,13 @@ def normalize_hostname(raw: Optional[str]) -> str: # Strip scheme for prefix in ("https://", "http://"): if cleaned.lower().startswith(prefix): - cleaned = cleaned[len(prefix):] + cleaned = cleaned[len(prefix) :] break # Strip path and trailing slashes - cleaned = cleaned.split("/")[0].strip() - return cleaned + return cleaned.split("/")[0].strip() -def parse_size(size_str: Optional[str]) -> Optional[int]: +def parse_size(size_str: str | None) -> int | None: """Parse size string to bytes. Args: @@ -32,6 +30,7 @@ def parse_size(size_str: Optional[str]) -> Optional[int]: Returns: Size in bytes, or None if parsing fails + """ if not size_str: return None @@ -47,9 +46,9 @@ def parse_size(size_str: Optional[str]) -> Optional[int]: multipliers = { "B": 1, "KB": 1024, - "MB": 1024 ** 2, - "GB": 1024 ** 3, - "TB": 1024 ** 4, + "MB": 1024**2, + "GB": 1024**3, + "TB": 1024**4, } return int(value * multipliers.get(unit, 1)) diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index b7ca07a4..04c361d2 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -1,47 +1,57 @@ """Direct download source - Anna's Archive/Libgen with fallback cascade.""" -from dataclasses import replace import itertools import json import re import time -from pathlib import Path -from threading import Event -from typing import Callable, Dict, List, Optional +from dataclasses import replace +from typing import TYPE_CHECKING, ClassVar, NoReturn from urllib.parse import quote import requests - from bs4 import BeautifulSoup, NavigableString, Tag -from shelfmark.download import http as downloader -from shelfmark.download import network from shelfmark.config.env import DEBUG_SKIP_SOURCES, TMP_DIR from shelfmark.core.config import config -from shelfmark.core.utils import CONTENT_TYPES, get_aa_content_type_dir, is_audiobook as check_audiobook from shelfmark.core.logger import setup_logger -from shelfmark.core.models import SearchFilters, DownloadTask, build_filename -from shelfmark.metadata_providers import BookMetadata, group_languages_by_localized_title +from shelfmark.core.models import DownloadTask, SearchFilters, build_filename +from shelfmark.core.utils import CONTENT_TYPES, get_aa_content_type_dir +from shelfmark.core.utils import is_audiobook as check_audiobook +from shelfmark.download import http as downloader +from shelfmark.download import network from shelfmark.release_sources import ( BrowseRecord, - Release, - ReleaseProtocol, - ReleaseSource, - DownloadHandler, - SourceUnavailableError, - register_source, - register_handler, - ReleaseColumnConfig, - ColumnSchema, - ColumnRenderType, ColumnAlign, ColumnColorHint, + ColumnRenderType, + ColumnSchema, + DownloadHandler, + Release, + ReleaseColumnConfig, + ReleaseProtocol, + ReleaseSource, + SourceUnavailableError, + register_handler, + register_source, ) +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + from threading import Event + + from shelfmark.core.search_plan import ReleaseSearchPlan + from shelfmark.metadata_providers import BookMetadata + logger = setup_logger(__name__) + +def _raise_runtime_error(message: str) -> NoReturn: + raise RuntimeError(message) + + _aa_slow_rotation = itertools.count() -_url_source_types: Dict[str, str] = {} +_url_source_types: dict[str, str] = {} if DEBUG_SKIP_SOURCES: logger.warning("DEBUG_SKIP_SOURCES active: skipping sources %s", DEBUG_SKIP_SOURCES) @@ -65,37 +75,51 @@ _CF_BYPASS_REQUIRED = frozenset({"aa-slow-nowait", "aa-slow-wait", "zlib", "weli # Sources whose URLs come from AA page (multiple mirrors) _AA_PAGE_SOURCES = frozenset({"aa-slow-nowait", "aa-slow-wait"}) -def _get_md5_url_template(source_id: str) -> Optional[str]: + +def _get_md5_url_template(source_id: str) -> str | None: """Get URL template for MD5-based sources from centralized config.""" from shelfmark.core import mirrors if source_id == "zlib": return mirrors.get_zlib_url_template() - elif source_id == "welib": + if source_id == "welib": return mirrors.get_welib_url_template() return None -def _get_libgen_domains() -> List[str]: +def _get_libgen_domains() -> list[str]: """Get LibGen domains from centralized config.""" from shelfmark.core import mirrors + return mirrors.get_libgen_mirrors() + _LIBGEN_GET_PATTERNS = [ - re.compile(r']*>\s*]*>GET\s*', re.IGNORECASE), - re.compile(r']+href=["\']([^"\']*get\.php\?md5=[^"\']+&(?:amp;)?key=[^"\']+)["\']', re.IGNORECASE), - re.compile(r']*>[\s\S]*?]*>GET', re.IGNORECASE), - re.compile(r'href=["\']([^"\']*get\.php\?[^"\']*md5=[^"\']*&[^"\']*key=[^"\']+)["\']', re.IGNORECASE), + re.compile( + r']*>\s*]*>GET\s*', + re.IGNORECASE, + ), + re.compile( + r']+href=["\']([^"\']*get\.php\?md5=[^"\']+&(?:amp;)?key=[^"\']+)["\']', re.IGNORECASE + ), + re.compile( + r']*>[\s\S]*?]*>GET', + re.IGNORECASE, + ), + re.compile( + r'href=["\']([^"\']*get\.php\?[^"\']*md5=[^"\']*&[^"\']*key=[^"\']+)["\']', re.IGNORECASE + ), ] -def _get_source_priority() -> List[Dict]: + +def _get_source_priority() -> list[dict]: """Get the full source priority list. Fast sources come from user config (FAST_SOURCES_DISPLAY). Slow sources come from user config. """ # Fast sources - always first, configurable via settings/env - fast_sources: List[Dict] = [] + fast_sources: list[dict] = [] configured_fast = config.get("FAST_SOURCES_DISPLAY") or [] has_donator_key = bool(config.get("AA_DONATOR_KEY")) @@ -131,7 +155,7 @@ def _is_source_enabled(source_id: str) -> bool: return False -_SIZE_UNIT_PATTERN = re.compile(r'(kb|mb|gb|tb)', re.IGNORECASE) +_SIZE_UNIT_PATTERN = re.compile(r"(kb|mb|gb|tb)", re.IGNORECASE) def _normalize_size(size_str: str) -> str: @@ -143,7 +167,7 @@ class SearchUnavailable(SourceUnavailableError): """Raised when Anna's Archive cannot be reached via any mirror/DNS.""" -def search_books(query: str, filters: SearchFilters) -> List[BrowseRecord]: +def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]: """Search for books matching the query. Args: @@ -156,13 +180,12 @@ def search_books(query: str, filters: SearchFilters) -> List[BrowseRecord]: Raises: SearchUnavailable: If Anna's Archive cannot be reached Exception: If parsing fails + """ query_html = quote(query) if filters.isbn: - isbns = " || ".join( - [f"('isbn13:{isbn}' || 'isbn10:{isbn}')" for isbn in filters.isbn] - ) + isbns = " || ".join([f"('isbn13:{isbn}' || 'isbn10:{isbn}')" for isbn in filters.isbn]) query_html = quote(f"({isbns}) {query}") filters_query = "" @@ -178,7 +201,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BrowseRecord]: for value in filters.content: filters_query += f"&content={quote(value)}" - formats_to_use = filters.format if filters.format else config.SUPPORTED_FORMATS + formats_to_use = filters.format or config.SUPPORTED_FORMATS index = 1 for filter_type, filter_values in vars(filters).items(): @@ -201,28 +224,27 @@ def search_books(query: str, filters: SearchFilters) -> List[BrowseRecord]: html = downloader.html_get_page(url, selector=selector, allow_bypasser_fallback=False) if not html: # Network/mirror exhaustion path bubbles up so API can notify clients - raise SearchUnavailable("Unable to reach download source. Network restricted or mirrors are blocked.") + raise SearchUnavailable( + "Unable to reach download source. Network restricted or mirrors are blocked." + ) if "No files found." in html: - logger.info(f"No books found for query: {query}") + logger.info("No books found for query: %s", query) return [] soup = BeautifulSoup(html, "html.parser") tbody: Tag | NavigableString | None = soup.find("table") if not tbody: - logger.warning(f"No results table found for query: {query}") - raise Exception("No books found. Please try another query.") + logger.warning("No results table found for query: %s", query) + raise RuntimeError("No books found. Please try another query.") books = [] if isinstance(tbody, Tag): for line_tr in tbody.find_all("tr"): - try: - book = _parse_search_result_row(line_tr) - if book: - books.append(book) - except Exception as e: - logger.error_trace(f"Failed to parse search result row: {e}") + book = _parse_search_result_row(line_tr) + if book: + books.append(book) books.sort( key=lambda x: ( @@ -235,7 +257,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BrowseRecord]: return books -def get_book_info(book_id: str, fetch_download_count: bool = True) -> BrowseRecord: +def get_book_info(book_id: str, *, fetch_download_count: bool = True) -> BrowseRecord: """Get detailed information for a specific book. Args: @@ -245,20 +267,23 @@ def get_book_info(book_id: str, fetch_download_count: bool = True) -> BrowseReco Returns: BrowseRecord: Detailed book information including download URLs + """ url = f"{network.get_aa_base_url()}/md5/{book_id}" selector = network.AAMirrorSelector() html = downloader.html_get_page(url, selector=selector, allow_bypasser_fallback=False) if not html: - raise SearchUnavailable("Unable to reach download source. Network restricted or mirrors are blocked.") + raise SearchUnavailable( + "Unable to reach download source. Network restricted or mirrors are blocked." + ) soup = BeautifulSoup(html, "html.parser") - return _parse_book_info_page(soup, book_id, fetch_download_count) + return _parse_book_info_page(soup, book_id, fetch_download_count=fetch_download_count) -def _parse_search_result_row(row: Tag) -> Optional[BrowseRecord]: +def _parse_search_result_row(row: Tag) -> BrowseRecord | None: """Parse a single search result row into a browse record.""" try: if row.text.strip().lower().startswith("your ad here"): @@ -280,27 +305,29 @@ def _parse_search_result_row(row: Tag) -> Optional[BrowseRecord]: format=cells[9].find("span").next.lower(), size=cells[10].find("span").next, ) - except Exception as e: + except (AttributeError, IndexError, KeyError, TypeError) as e: logger.error_trace(f"Error parsing search result row: {e}") return None -def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_count: bool = True) -> BrowseRecord: +def _parse_book_info_page( + soup: BeautifulSoup, + book_id: str, + *, + fetch_download_count: bool = True, +) -> BrowseRecord: """Parse the book info page HTML into a browse record.""" data = soup.select_one("body > main > div:nth-of-type(1)") if not data: - raise Exception(f"Failed to parse book info for ID: {book_id}") + raise RuntimeError(f"Failed to parse book info for ID: {book_id}") preview: str = "" node = data.select_one("div:nth-of-type(1) > img") if node: preview_value = node.get("src", "") - if isinstance(preview_value, list): - preview = preview_value[0] - else: - preview = preview_value + preview = preview_value[0] if isinstance(preview_value, list) else preview_value data = soup.find_all("div", {"class": "main-inner"})[0].find_next("div") divs = list(data.children) @@ -317,14 +344,14 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_coun next_text = "" if anchor.next and anchor.next.next: - next_text = getattr(anchor.next.next, 'text', str(anchor.next.next)).strip().lower() + next_text = getattr(anchor.next.next, "text", str(anchor.next.next)).strip().lower() if text.startswith("slow partner server") and "waitlist" in next_text: if "no waitlist" in next_text: slow_urls_no_waitlist.add(href) else: slow_urls_with_waitlist.add(href) - except Exception: + except AttributeError, TypeError: pass logger.debug( @@ -403,13 +430,22 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_coun if fetch_download_count: try: summary_url = f"{network.get_aa_base_url()}/dyn/md5/summary/{book_id}" - summary_response = downloader.html_get_page(summary_url, selector=network.AAMirrorSelector(), allow_bypasser_fallback=False) + summary_response = downloader.html_get_page( + summary_url, selector=network.AAMirrorSelector(), allow_bypasser_fallback=False + ) if summary_response: summary_data = json.loads(summary_response) if "downloads_total" in summary_data: info["Downloads"] = [str(summary_data["downloads_total"])] - except Exception as e: - logger.debug(f"Failed to fetch download count for {book_id}: {e}") + except ( + SearchUnavailable, + RuntimeError, + json.JSONDecodeError, + TypeError, + KeyError, + AttributeError, + ) as e: + logger.debug("Failed to fetch download count for %s: %s", book_id, e) book_info.info = info @@ -425,7 +461,7 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_coun return book_info -def _find_in_divs(divs: List, text: str, is_class: bool = False) -> List[str]: +def _find_in_divs(divs: list, text: str, *, is_class: bool = False) -> list[str]: """Find divs containing text or having a specific class.""" results = [] for div in divs: @@ -437,7 +473,7 @@ def _find_in_divs(divs: List, text: str, is_class: bool = False) -> List[str]: return results -def _get_next_value_div(label_div: Tag) -> Optional[Tag]: +def _get_next_value_div(label_div: Tag) -> Tag | None: """Find the next sibling div that holds the value for a metadata label.""" sibling = label_div.next_sibling while sibling: @@ -447,14 +483,13 @@ def _get_next_value_div(label_div: Tag) -> Optional[Tag]: return None -def _extract_book_description(soup: BeautifulSoup) -> Optional[str]: +def _extract_book_description(soup: BeautifulSoup) -> str | None: """Extract the primary or alternative description from the book page.""" container = soup.select_one(".js-md5-top-box-description") if not container: return None - description: Optional[str] = None - alternative: Optional[str] = None + alternative: str | None = None label_divs = container.select("div.text-xs.text-gray-500.uppercase") for label_div in label_divs: @@ -485,9 +520,9 @@ def _extract_book_description(soup: BeautifulSoup) -> Optional[str]: return None -def _extract_book_metadata(metadata_divs) -> Dict[str, List[str]]: +def _extract_book_metadata(metadata_divs: Tag) -> dict[str, list[str]]: """Extract metadata from book info divs.""" - info: Dict[str, set[str]] = {} + info: dict[str, set[str]] = {} sub_datas = metadata_divs.find_all("div")[0] for sub_data in sub_datas.children: @@ -516,6 +551,7 @@ def _get_source_info(link: str) -> tuple[str, str]: Returns: Tuple of (log_label, friendly_name) + """ # Check detailed source type mapping first (for AA slow distinction) if link in _url_source_types: @@ -535,7 +571,7 @@ def _friendly_source_name(link: str) -> str: return _get_source_info(link)[1] -def _group_urls_by_source(urls: List[str], urls_by_source: Dict[str, List[str]]) -> None: +def _group_urls_by_source(urls: list[str], urls_by_source: dict[str, list[str]]) -> None: """Group URLs into urls_by_source dict by their source type.""" for url in urls: source_type = _url_source_types.get(url) @@ -543,7 +579,7 @@ def _group_urls_by_source(urls: List[str], urls_by_source: Dict[str, List[str]]) urls_by_source.setdefault(source_type, []).append(url) -def _fetch_aa_page_urls(book_info: BrowseRecord, urls_by_source: Dict[str, List[str]]) -> None: +def _fetch_aa_page_urls(book_info: BrowseRecord, urls_by_source: dict[str, list[str]]) -> None: """Fetch and parse AA page, populating urls_by_source dict. Groups existing book_info.download_urls by source type. If book_info @@ -556,18 +592,18 @@ def _fetch_aa_page_urls(book_info: BrowseRecord, urls_by_source: Dict[str, List[ try: fresh_book_info = get_book_info(book_info.id, fetch_download_count=False) _group_urls_by_source(fresh_book_info.download_urls, urls_by_source) - except Exception as e: - logger.warning(f"Failed to fetch AA page: {e}") + except (SearchUnavailable, RuntimeError, TypeError, AttributeError) as e: + logger.warning("Failed to fetch AA page: %s", e) def _get_urls_for_source( source_id: str, book_info: BrowseRecord, selector: network.AAMirrorSelector, - cancel_flag: Optional[Event], - status_callback: Optional[Callable[[str, Optional[str]], None]], - urls_by_source: Dict[str, List[str]], -) -> List[str]: + cancel_flag: Event | None, + status_callback: Callable[[str, str | None], None] | None, + urls_by_source: dict[str, list[str]], +) -> list[str]: """Get URLs for a specific source, fetching lazily if needed.""" # AA Fast - generate URL dynamically if source_id == "aa-fast": @@ -596,7 +632,12 @@ def _get_urls_for_source( if source_id == "welib": if status_callback: status_callback("resolving", "Fetching welib sources") - return _get_download_urls_from_welib(book_info.id, selector=selector, cancel_flag=cancel_flag, status_callback=status_callback) + return _get_download_urls_from_welib( + book_info.id, + selector=selector, + cancel_flag=cancel_flag, + status_callback=status_callback, + ) # AA page sources - fetch AA page if not already done if source_id in _AA_PAGE_SOURCES: @@ -615,74 +656,101 @@ def _try_download_url( source_id: str, book_info: BrowseRecord, book_path: Path, - progress_callback: Optional[Callable[[float], None]], - cancel_flag: Optional[Event], - status_callback: Optional[Callable[[str, Optional[str]], None]], + progress_callback: Callable[[float], None] | None, + cancel_flag: Event | None, + status_callback: Callable[[str, str | None], None] | None, selector: network.AAMirrorSelector, - source_context: str -) -> Optional[str]: + source_context: str, +) -> str | None: """Attempt to download from a single URL. Returns: download URL on success, None on failure. """ try: - logger.info(f"Trying download source [{source_id}]: {url}") + logger.info("Trying download source [%s]: %s", source_id, url) if status_callback: status_callback("resolving", f"Trying {source_context}") - download_url = _get_download_url(url, book_info.title, cancel_flag, status_callback, selector, source_context) + download_url = _get_download_url( + url, book_info.title, cancel_flag, status_callback, selector, source_context + ) if not download_url: - raise Exception("No download URL resolved") + _raise_runtime_error("No download URL resolved") - logger.info(f"Resolved download URL [{source_id}]: {download_url}") + logger.info("Resolved download URL [%s]: %s", source_id, download_url) data = downloader.download_url( - download_url, book_info.size or "", - progress_callback, cancel_flag, selector, - status_callback, referer=url + download_url, + book_info.size or "", + progress_callback, + cancel_flag, + selector, + status_callback, + referer=url, ) if not data: - raise Exception("No data received from download") + _raise_runtime_error("No data received from download") file_size = data.tell() if file_size < _MIN_VALID_FILE_SIZE: - logger.warning(f"Downloaded file too small ({file_size} bytes), likely an error page") - raise Exception(f"File too small ({file_size} bytes)") + logger.warning("Downloaded file too small (%s bytes), likely an error page", file_size) + _raise_runtime_error(f"File too small ({file_size} bytes)") - logger.debug(f"Download finished ({file_size} bytes). Writing to {book_path}") + logger.debug("Download finished (%s bytes). Writing to %s", file_size, book_path) data.seek(0) - with open(book_path, "wb") as f: + with book_path.open("wb") as f: f.write(data.getbuffer()) - return download_url - - except Exception as e: - logger.warning(f"Failed to download from {url} (source={source_id}): {e}") + except ( + RuntimeError, + requests.exceptions.RequestException, + OSError, + KeyError, + ValueError, + TypeError, + AttributeError, + ) as e: + logger.warning("Failed to download from %s (source=%s): %s", url, source_id, e) return None + else: + return download_url def _get_download_urls_from_welib( book_id: str, - selector: Optional[network.AAMirrorSelector] = None, - cancel_flag: Optional[Event] = None, - status_callback: Optional[Callable[[str, Optional[str]], None]] = None -) -> List[str]: + selector: network.AAMirrorSelector | None = None, + cancel_flag: Event | None = None, + status_callback: Callable[[str, str | None], None] | None = None, +) -> list[str]: """Get download URLs from welib.org (bypasser required).""" from shelfmark.core import mirrors if not _is_source_enabled("welib"): return [] url = mirrors.get_welib_url_template().format(md5=book_id) - logger.info(f"Fetching welib download URLs for {book_id}") + logger.info("Fetching welib download URLs for %s", book_id) try: - html = downloader.html_get_page(url, use_bypasser=True, selector=selector or network.AAMirrorSelector(), cancel_flag=cancel_flag, status_callback=status_callback) - except Exception as exc: + html = downloader.html_get_page( + url, + use_bypasser=True, + selector=selector or network.AAMirrorSelector(), + cancel_flag=cancel_flag, + status_callback=status_callback, + ) + except ( + SearchUnavailable, + requests.exceptions.RequestException, + RuntimeError, + ValueError, + TypeError, + AttributeError, + ) as exc: logger.error_trace(f"Welib fetch failed for {book_id}: {exc}") return [] if not html: - logger.warning(f"Welib page empty for {book_id}") + logger.warning("Welib page empty for %s", book_id) return [] soup = BeautifulSoup(html, "html.parser") @@ -694,13 +762,13 @@ def _get_download_urls_from_welib( return list(dict.fromkeys(links)) # Dedupe while preserving order -def _extract_libgen_download_url(link: str, cancel_flag: Optional[Event] = None) -> str: +def _extract_libgen_download_url(link: str, cancel_flag: Event | None = None) -> str: """Extract download URL from Libgen ads.php page using direct HTTP.""" if cancel_flag and cancel_flag.is_set(): return "" base_url = "/".join(link.split("/")[:3]) - logger.debug(f"Libgen fast: trying {link}") + logger.debug("Libgen fast: trying %s", link) try: response = requests.get( @@ -713,51 +781,53 @@ def _extract_libgen_download_url(link: str, cancel_flag: Optional[Event] = None) ) if response.status_code != 200: - logger.debug(f"Libgen fast: {link} returned {response.status_code}") + logger.debug("Libgen fast: %s returned %s", link, response.status_code) return "" html = response.text final_url = response.url if "libgen" not in final_url.lower() and "ads.php" not in final_url.lower(): - logger.debug(f"Libgen fast: redirected away to {final_url}") + logger.debug("Libgen fast: redirected away to %s", final_url) return "" if "get.php" not in html: - logger.debug(f"Libgen fast: page doesn't contain get.php") + logger.debug("Libgen fast: page doesn't contain get.php") return "" download_url = None for pattern in _LIBGEN_GET_PATTERNS: match = pattern.search(html) if match: - download_url = match.group(1).replace("&", "&").replace(">", ">").replace("<", "<") + download_url = ( + match.group(1).replace("&", "&").replace(">", ">").replace("<", "<") + ) break if not download_url: - logger.debug(f"Libgen fast: couldn't extract GET link") + logger.debug("Libgen fast: couldn't extract GET link") return "" if not download_url.startswith("http"): download_url = f"{base_url}/{download_url.lstrip('/')}" - logger.debug(f"Libgen fast: extracted {download_url}") - return download_url - + logger.debug("Libgen fast: extracted %s", download_url) except requests.exceptions.RequestException as e: - logger.debug(f"Libgen fast: request failed: {e}") + logger.debug("Libgen fast: request failed: %s", e) return "" - except Exception as e: - logger.warning(f"Libgen fast: unexpected error: {e}") + except (AttributeError, TypeError, ValueError) as e: + logger.warning("Libgen fast: unexpected error: %s", e) return "" + else: + return download_url def _download_book( book_info: BrowseRecord, book_path: Path, - progress_callback: Optional[Callable[[float], None]] = None, - cancel_flag: Optional[Event] = None, - status_callback: Optional[Callable[[str, Optional[str]], None]] = None -) -> Optional[str]: + progress_callback: Callable[[float], None] | None = None, + cancel_flag: Event | None = None, + status_callback: Callable[[str, str | None], None] | None = None, +) -> str | None: """Download a book using sources in configured priority order. Returns: Download URL if successful, None otherwise. @@ -783,17 +853,21 @@ def _download_book( # Skip if source requires CF bypass and it's not enabled if source_id in _CF_BYPASS_REQUIRED and not config.USE_CF_BYPASS: - logger.debug(f"Skipping {source_id} - requires CF bypass") + logger.debug("Skipping %s - requires CF bypass", source_id) continue # Skip if source has failed too many times if source_failures.get(source_id, 0) >= _SOURCE_FAILURE_THRESHOLD: - logger.debug(f"Skipping {source_id} - too many failures") + logger.debug("Skipping %s - too many failures", source_id) continue # Get URLs for this source (lazy-loads as needed) urls_to_try = _get_urls_for_source( - source_id, book_info, selector, cancel_flag, status_callback, + source_id, + book_info, + selector, + cancel_flag, + status_callback, urls_by_source, ) @@ -806,7 +880,7 @@ def _download_book( rotation = rotation_value % len(urls_to_try) urls_to_try = urls_to_try[rotation:] + urls_to_try[:rotation] if rotation: - logger.debug(f"Rotated {source_id} URLs by {rotation}") + logger.debug("Rotated %s URLs by %s", source_id, rotation) # Try each URL for this source for url in urls_to_try: @@ -821,9 +895,15 @@ def _download_book( source_context = f"{friendly_name} (Server #{url_attempt_counter})" result = _try_download_url( - url, source_id, book_info, book_path, - progress_callback, cancel_flag, status_callback, selector, - source_context + url, + source_id, + book_info, + book_path, + progress_callback, + cancel_flag, + status_callback, + selector, + source_context, ) if result: @@ -833,7 +913,7 @@ def _download_book( # Check if we've hit the failure threshold if source_failures[source_id] >= _SOURCE_FAILURE_THRESHOLD: - logger.info(f"Source {source_id} hit failure threshold, moving to next source") + logger.info("Source %s hit failure threshold, moving to next source", source_id) break if status_callback: @@ -844,10 +924,10 @@ def _download_book( def _get_download_url( link: str, title: str, - cancel_flag: Optional[Event] = None, - status_callback: Optional[Callable[[str, Optional[str]], None]] = None, - selector: Optional[network.AAMirrorSelector] = None, - source_context: Optional[str] = None + cancel_flag: Event | None = None, + status_callback: Callable[[str, str | None], None] | None = None, + selector: network.AAMirrorSelector | None = None, + source_context: str | None = None, ) -> str: """Extract actual download URL from various source pages. @@ -858,18 +938,23 @@ def _get_download_url( status_callback: Optional callback for status updates selector: Optional AA mirror selector source_context: Optional context string like "Welib (1/12)" for status messages + """ sel = selector or network.AAMirrorSelector() # AA fast download API (JSON response) if link.startswith(f"{network.get_aa_base_url()}/dyn/api/fast_download.json"): - page = downloader.html_get_page(link, selector=sel, cancel_flag=cancel_flag, status_callback=status_callback) + page = downloader.html_get_page( + link, selector=sel, cancel_flag=cancel_flag, status_callback=status_callback + ) return downloader.get_absolute_url(link, json.loads(page).get("download_url", "")) if "/ads.php?md5=" in link and any(domain in link for domain in _get_libgen_domains()): return _extract_libgen_download_url(link, cancel_flag) - html = downloader.html_get_page(link, selector=sel, cancel_flag=cancel_flag, status_callback=status_callback) + html = downloader.html_get_page( + link, selector=sel, cancel_flag=cancel_flag, status_callback=status_callback + ) if not html: return "" @@ -882,7 +967,9 @@ def _get_download_url( if not dl: # Retry after delay if page not fully loaded time.sleep(2) - html = downloader.html_get_page(link, selector=sel, cancel_flag=cancel_flag, status_callback=status_callback) + html = downloader.html_get_page( + link, selector=sel, cancel_flag=cancel_flag, status_callback=status_callback + ) if html: soup = BeautifulSoup(html, "html.parser") dl = soup.find("a", href=True, class_="addDownloadedBook") @@ -890,14 +977,16 @@ def _get_download_url( # AA slow download / partner servers elif "/slow_download/" in link: - url = _extract_slow_download_url(soup, link, title, cancel_flag, status_callback, sel, source_context) + url = _extract_slow_download_url( + soup, link, title, cancel_flag, status_callback, sel, source_context + ) else: get_btn = soup.find("a", string="GET") or soup.find("a", string="Download") if get_btn: url = get_btn.get("href", "") else: - logger.warning(f"Unknown source type, couldn't find download link: {link}") + logger.warning("Unknown source type, couldn't find download link: %s", link) url = "" return downloader.get_absolute_url(link, url) @@ -907,10 +996,10 @@ def _extract_slow_download_url( soup: BeautifulSoup, link: str, title: str, - cancel_flag: Optional[Event], - status_callback, - selector, - source_context: Optional[str] = None + cancel_flag: Event | None, + status_callback: Callable[[str, str | None], None] | None, + selector: network.AAMirrorSelector, + source_context: str | None = None, ) -> str: """Extract download URL from AA slow download pages.""" html_str = str(soup) @@ -959,7 +1048,11 @@ def _extract_slow_download_url( if code_elem: return code_elem.get_text(strip=True) for sibling in parent.find_next_siblings(): - text = sibling.get_text(strip=True) if hasattr(sibling, 'get_text') else str(sibling).strip() + text = ( + sibling.get_text(strip=True) + if hasattr(sibling, "get_text") + else str(sibling).strip() + ) if text.startswith("http"): return text @@ -968,28 +1061,38 @@ def _extract_slow_download_url( MAX_COUNTDOWN_SECONDS = 600 sleep_time = min(countdown_seconds, MAX_COUNTDOWN_SECONDS) if countdown_seconds > MAX_COUNTDOWN_SECONDS: - logger.warning(f"Countdown {countdown_seconds}s exceeds max, capping at {MAX_COUNTDOWN_SECONDS}s") - logger.info(f"AA waitlist: {sleep_time}s for {title}") + logger.warning( + "Countdown %ss exceeds max, capping at %ss", + countdown_seconds, + MAX_COUNTDOWN_SECONDS, + ) + logger.info("AA waitlist: %ss for %s", sleep_time, title) # Live countdown with status updates for remaining in range(sleep_time, 0, -1): - wait_msg = f"{source_context} - Waiting {remaining}s" if source_context else f"Waiting {remaining}s" + wait_msg = ( + f"{source_context} - Waiting {remaining}s" + if source_context + else f"Waiting {remaining}s" + ) if status_callback: status_callback("resolving", wait_msg) # Wait 1 second (or until cancelled) if cancel_flag and cancel_flag.wait(timeout=1): - logger.info(f"Cancelled wait for {title}") + logger.info("Cancelled wait for %s", title) return "" # After countdown, update status and re-fetch if status_callback and source_context: status_callback("resolving", f"{source_context} - Fetching") - return _get_download_url(link, title, cancel_flag, status_callback, selector, source_context) + return _get_download_url( + link, title, cancel_flag, status_callback, selector, source_context + ) link_texts = [a.get_text(strip=True)[:50] for a in soup.find_all("a", href=True)[:10]] - logger.warning(f"No download URL found. First 10 links: {link_texts}") + logger.warning("No download URL found. First 10 links: %s", link_texts) return "" @@ -997,20 +1100,16 @@ def _extract_countdown_seconds(soup: BeautifulSoup, html_str: str) -> int: """Extract countdown timer seconds from AA slow download page.""" countdown_elem = soup.find("span", class_="js-partner-countdown") if countdown_elem: - try: - seconds = int(countdown_elem.get_text(strip=True)) - if 0 < seconds < 300: - return seconds - except (ValueError, TypeError): - pass + seconds = _parse_countdown_seconds_from_element(countdown_elem) + if seconds is not None: + return seconds - for elem in soup.find_all(["span", "div"], class_=lambda c: c and ("timer" in c.lower() or "countdown" in c.lower())): - try: - seconds = int(elem.get_text(strip=True)) - if 0 < seconds < 300: - return seconds - except (ValueError, TypeError): - pass + for elem in soup.find_all( + ["span", "div"], class_=lambda c: c and ("timer" in c.lower() or "countdown" in c.lower()) + ): + seconds = _parse_countdown_seconds_from_element(elem) + if seconds is not None: + return seconds countdown_attr = re.search(r'data-countdown=["\'](\d+)["\']', html_str) if countdown_attr: @@ -1018,19 +1117,18 @@ def _extract_countdown_seconds(soup: BeautifulSoup, html_str: str) -> int: if 0 < seconds < 300: return seconds - js_countdown = re.search(r'countdown:\s*(\d+)', html_str) + js_countdown = re.search(r"countdown:\s*(\d+)", html_str) if js_countdown: seconds = int(js_countdown.group(1)) if 0 < seconds < 300: return seconds - - js_var = re.search(r'(?:var|let|const)\s+countdown\s*=\s*(\d+)', html_str) + js_var = re.search(r"(?:var|let|const)\s+countdown\s*=\s*(\d+)", html_str) if js_var: seconds = int(js_var.group(1)) if 0 < seconds < 300: return seconds - countdown_secs = re.search(r'countdownSeconds\s*=\s*(\d+)', html_str) + countdown_secs = re.search(r"countdownSeconds\s*=\s*(\d+)", html_str) if countdown_secs: seconds = int(countdown_secs.group(1)) if 0 < seconds < 300: @@ -1042,7 +1140,7 @@ def _extract_countdown_seconds(soup: BeautifulSoup, html_str: str) -> int: if 0 < seconds < 300: return seconds - wait_text = re.search(r'wait\s+(\d+)\s+seconds', html_str, re.IGNORECASE) + wait_text = re.search(r"wait\s+(\d+)\s+seconds", html_str, re.IGNORECASE) if wait_text: seconds = int(wait_text.group(1)) if 0 < seconds < 300: @@ -1051,6 +1149,18 @@ def _extract_countdown_seconds(soup: BeautifulSoup, html_str: str) -> int: return 0 +def _parse_countdown_seconds_from_element(element: Tag) -> int | None: + """Parse an integer countdown from a tag, returning None when invalid.""" + try: + seconds = int(element.get_text(strip=True)) + except ValueError, TypeError: + return None + + if 0 < seconds < 300: + return seconds + return None + + def _browse_record_to_release(record: BrowseRecord) -> Release: """Convert a browse record to a Release object. @@ -1077,23 +1187,23 @@ def _browse_record_to_release(record: BrowseRecord) -> Release: "description": record.description, "download_urls": record.download_urls, "info": record.info, - } + }, ) @register_source("direct_download") class DirectDownloadSource(ReleaseSource): - """ - Direct download source - searches web sources for books. + """Direct download source - searches web sources for books. This wraps the search_books() functionality to provide releases via the plugin interface. """ + name = "direct_download" display_name = "Direct Download" - supported_content_types = ["ebook"] # Direct downloads only support ebooks + supported_content_types: ClassVar[list[str]] = ["ebook"] # Direct downloads only support ebooks - def __init__(self): + def __init__(self) -> None: # Tracks which search method was used in the last search() call # "isbn" = ISBN search returned results, "title_author" = title+author was used self._last_search_type: str = "title_author" @@ -1149,7 +1259,7 @@ class DirectDownloadSource(ReleaseSource): record_id: str, *, fetch_download_count: bool = True, - ) -> Optional[BrowseRecord]: + ) -> BrowseRecord | None: """Resolve a direct-download record for direct-mode info/download flows.""" return get_book_info(record_id, fetch_download_count=fetch_download_count) @@ -1157,7 +1267,7 @@ class DirectDownloadSource(ReleaseSource): """Direct search results already represent concrete downloadable releases.""" return True - def get_destination_override(self, task: DownloadTask) -> Optional[Path]: + def get_destination_override(self, task: DownloadTask) -> Path | None: """Apply Anna's Archive content-type routing when configured.""" if check_audiobook(task.content_type): return None @@ -1169,26 +1279,28 @@ class DirectDownloadSource(ReleaseSource): filters: SearchFilters, *, search_label: str, - ) -> List[BrowseRecord]: + ) -> list[BrowseRecord]: """Retry AA queries without a language filter when filtered search returns nothing.""" results = search_books(query, filters) if results or not filters.lang: return results logger.debug( - f"No {search_label} results with langs={filters.lang}, retrying without language filter" + "No %s results with langs=%s, retrying without language filter", + search_label, + filters.lang, ) return search_books(query, replace(filters, lang=None)) def search( self, book: BookMetadata, - plan: "ReleaseSearchPlan", # noqa: F821 + plan: ReleaseSearchPlan, + *, expand_search: bool = False, - content_type: str = "ebook" - ) -> List[Release]: - """ - Search for releases using the book's metadata. + content_type: str = "ebook", + ) -> list[Release]: + """Search for releases using the book's metadata. Priority: ISBN search first (most precise), then title+author fallback. For non-English languages, uses localized titles from book.titles_by_language. @@ -1198,6 +1310,7 @@ class DirectDownloadSource(ReleaseSource): expand_search: If True, skip ISBN and use title+author directly languages: Language codes to filter by (overrides book.language/config) content_type: Ignored - Direct download uses format filtering instead + """ lang_filter = plan.languages @@ -1206,10 +1319,14 @@ class DirectDownloadSource(ReleaseSource): if plan.source_filters is not None: query = plan.manual_query or "" - logger.debug(f"Searching direct_download: source_query='{query}', langs={lang_filter}") + logger.debug( + "Searching direct_download: source_query='%s', langs=%s", query, lang_filter + ) filters = plan.source_filters or SearchFilters() filters.lang = lang_filter if lang_filter is not None else (filters.lang or []) - results = self._search_books_with_language_fallback(query, filters, search_label="manual") + results = self._search_books_with_language_fallback( + query, filters, search_label="manual" + ) self._last_search_type = "manual" if query else "title_author" return [_browse_record_to_release(record) for record in results] @@ -1220,20 +1337,20 @@ class DirectDownloadSource(ReleaseSource): if not expand_search: isbn = plan.isbn_candidates[0] if plan.isbn_candidates else None if isbn: - logger.debug(f"Searching direct_download: isbn='{isbn}', langs={lang_filter}") + logger.debug("Searching direct_download: isbn='%s', langs=%s", isbn, lang_filter) filters = SearchFilters(isbn=[isbn]) filters.lang = lang_filter if lang_filter is not None else [] try: results = search_books(isbn, filters) if results: - logger.info(f"Found {len(results)} releases via ISBN") + logger.info("Found %s releases via ISBN", len(results)) self._last_search_type = "isbn" return [_browse_record_to_release(record) for record in results] logger.debug("No ISBN results, falling back to title+author") except SearchUnavailable: raise - except Exception as e: - logger.warning(f"ISBN search failed: {e}") + except (ValueError, TypeError, AttributeError, RuntimeError) as e: + logger.warning("ISBN search failed: %s", e) # Title + author fallback author = plan.author @@ -1241,14 +1358,14 @@ class DirectDownloadSource(ReleaseSource): # Execute searches with deduplication seen_ids: set = set() - all_results: List[BrowseRecord] = [] + all_results: list[BrowseRecord] = [] for title, langs in searches: query = f"{title} {author}".strip() if not query: continue - logger.debug(f"Searching direct_download: title_author='{query}', langs={langs}") + logger.debug("Searching direct_download: title_author='%s', langs=%s", query, langs) filters = SearchFilters(lang=langs if langs is not None else []) try: for bi in search_books(query, filters): @@ -1257,17 +1374,19 @@ class DirectDownloadSource(ReleaseSource): all_results.append(bi) except SearchUnavailable: raise - except Exception as e: - logger.error(f"Search error: {e}") + except Exception: + logger.exception("Search error") if not all_results and any(langs for _, langs in searches): - logger.debug("No title+author results with language filter, retrying without language filter") + logger.debug( + "No title+author results with language filter, retrying without language filter" + ) for title, _langs in searches: query = f"{title} {author}".strip() if not query: continue - logger.debug(f"Searching direct_download: title_author='{query}', langs=[]") + logger.debug("Searching direct_download: title_author='%s', langs=[]", query) try: for bi in search_books(query, SearchFilters()): if bi.id not in seen_ids: @@ -1275,10 +1394,10 @@ class DirectDownloadSource(ReleaseSource): all_results.append(bi) except SearchUnavailable: raise - except Exception as e: - logger.error(f"Search error: {e}") + except Exception: + logger.exception("Search error") - logger.info(f"Found {len(all_results)} releases via title+author") + logger.info("Found %s releases via title+author", len(all_results)) return [_browse_record_to_release(record) for record in all_results] def is_available(self) -> bool: @@ -1288,8 +1407,7 @@ class DirectDownloadSource(ReleaseSource): @register_handler("direct_download") class DirectDownloadHandler(DownloadHandler): - """ - Handler for direct HTTP downloads from Anna's Archive, Libgen, etc. + """Handler for direct HTTP downloads from Anna's Archive, Libgen, etc. Receives a DownloadTask with task_id (AA MD5 hash) and cascades through sources in priority order. The AA page is only fetched if AA slow sources @@ -1301,10 +1419,9 @@ class DirectDownloadHandler(DownloadHandler): task: DownloadTask, cancel_flag: Event, progress_callback: Callable[[float], None], - status_callback: Callable[[str, Optional[str]], None] - ) -> Optional[str]: - """ - Execute a direct HTTP download. + status_callback: Callable[[str, str | None], None], + ) -> str | None: + """Execute a direct HTTP download. Uses task.task_id (AA MD5 hash) to cascade through sources in priority order. The AA page is only fetched if AA slow sources are enabled. @@ -1317,11 +1434,12 @@ class DirectDownloadHandler(DownloadHandler): Returns: Path to downloaded file if successful, None otherwise + """ try: # Check for cancellation before starting if cancel_flag.is_set(): - logger.info(f"Download cancelled before starting: {task.task_id}") + logger.info("Download cancelled before starting: %s", task.task_id) status_callback("cancelled", "Cancelled") return None @@ -1340,18 +1458,15 @@ class DirectDownloadHandler(DownloadHandler): ) return self._execute_download( - book_info, - cancel_flag, - progress_callback, - status_callback + book_info, cancel_flag, progress_callback, status_callback ) except Exception as e: if cancel_flag.is_set(): - logger.info(f"Download cancelled during error handling: {task.task_id}") + logger.info("Download cancelled during error handling: %s", task.task_id) status_callback("cancelled", "Cancelled") else: - logger.error(f"Error downloading book: {e}") + logger.exception("Error downloading book") status_callback("error", str(e)) return None @@ -1360,10 +1475,9 @@ class DirectDownloadHandler(DownloadHandler): book_info: BrowseRecord, cancel_flag: Event, progress_callback: Callable[[float], None], - status_callback: Callable[[str, Optional[str]], None] - ) -> Optional[str]: - """ - Internal method to execute the download with fetched browse record. + status_callback: Callable[[str, str | None], None], + ) -> str | None: + """Internal method to execute the download with fetched browse record. This contains the core download logic: cascade through sources, handle bypass, move to final location. @@ -1387,23 +1501,19 @@ class DirectDownloadHandler(DownloadHandler): # Check cancellation before download if cancel_flag.is_set(): - logger.info(f"Download cancelled before download call: {book_info.id}") + logger.info("Download cancelled before download call: %s", book_info.id) status_callback("cancelled", "Cancelled") return None # Execute download via _download_book (handles cascade and bypass) status_callback("resolving", "Finding download source") success_url = _download_book( - book_info, - book_path, - progress_callback, - cancel_flag, - status_callback + book_info, book_path, progress_callback, cancel_flag, status_callback ) # Check for cancellation after download if cancel_flag.is_set(): - logger.info(f"Download cancelled during download: {book_info.id}") + logger.info("Download cancelled during download: %s", book_info.id) if book_path.exists(): book_path.unlink() status_callback("cancelled", "Cancelled") @@ -1416,12 +1526,12 @@ class DirectDownloadHandler(DownloadHandler): # Return temp path - orchestrator handles post-processing (archive extraction, ingest) return str(book_path) - except Exception as e: + except Exception: if cancel_flag.is_set(): - logger.info(f"Download cancelled during error handling: {book_info.id}") + logger.info("Download cancelled during error handling: %s", book_info.id) status_callback("cancelled", "Cancelled") else: - logger.error(f"Error downloading book: {e}") + logger.exception("Error downloading book") return None def cancel(self, task_id: str) -> bool: diff --git a/shelfmark/release_sources/irc/__init__.py b/shelfmark/release_sources/irc/__init__.py index 5739890e..5c6f2baa 100644 --- a/shelfmark/release_sources/irc/__init__.py +++ b/shelfmark/release_sources/irc/__init__.py @@ -6,6 +6,6 @@ Available when IRC server, channel, and nickname are configured in settings. Based on OpenBooks (https://github.com/evan-buss/openbooks). """ -from shelfmark.release_sources.irc import source # noqa: F401 -from shelfmark.release_sources.irc import handler # noqa: F401 -from shelfmark.release_sources.irc import settings # noqa: F401 +from shelfmark.release_sources.irc import handler as handler +from shelfmark.release_sources.irc import settings as settings +from shelfmark.release_sources.irc import source as source diff --git a/shelfmark/release_sources/irc/cache.py b/shelfmark/release_sources/irc/cache.py index 03060d02..0fdbe870 100644 --- a/shelfmark/release_sources/irc/cache.py +++ b/shelfmark/release_sources/irc/cache.py @@ -9,7 +9,7 @@ import time from dataclasses import asdict from pathlib import Path from threading import Lock -from typing import Any, Dict, List, Optional +from typing import Any from shelfmark.config import env from shelfmark.core.logger import setup_logger @@ -28,46 +28,48 @@ DEFAULT_CACHE_TTL = 30 * 24 * 60 * 60 _cache_lock = Lock() -def _generate_cache_key(provider: str, provider_id: str, content_type: Optional[str] = None) -> str: +def _generate_cache_key(provider: str, provider_id: str, content_type: str | None = None) -> str: """Generate a cache key from provider, provider_id, and content type.""" normalized_content_type = "audiobook" if check_audiobook(content_type) else "ebook" return f"{provider}:{provider_id}:{normalized_content_type}" -def _load_cache() -> Dict[str, Any]: +def _load_cache() -> dict[str, Any]: """Load cache from disk.""" try: if CACHE_FILE.exists(): return json.loads(CACHE_FILE.read_text()) - except (json.JSONDecodeError, IOError) as e: - logger.warning(f"Failed to load IRC cache: {e}") + except (OSError, json.JSONDecodeError) as e: + logger.warning("Failed to load IRC cache: %s", e) return {"entries": {}, "version": 1} -def _save_cache(cache: Dict[str, Any]) -> None: +def _save_cache(cache: dict[str, Any]) -> None: """Save cache to disk.""" try: CACHE_FILE.write_text(json.dumps(cache, indent=2)) - except IOError as e: - logger.error(f"Failed to save IRC cache: {e}") + except OSError: + logger.exception("Failed to save IRC cache") -def _release_to_dict(release: Release) -> Dict[str, Any]: +def _release_to_dict(release: Release) -> dict[str, Any]: """Convert Release to a JSON-serializable dict.""" data = asdict(release) # Convert enum to string if data.get("protocol"): - data["protocol"] = data["protocol"].value if hasattr(data["protocol"], "value") else str(data["protocol"]) + data["protocol"] = ( + data["protocol"].value if hasattr(data["protocol"], "value") else str(data["protocol"]) + ) return data -def _dict_to_release(data: Dict[str, Any]) -> Release: +def _dict_to_release(data: dict[str, Any]) -> Release: """Convert dict back to Release object.""" # Convert protocol string back to enum if data.get("protocol"): try: data["protocol"] = ReleaseProtocol(data["protocol"]) - except (ValueError, KeyError): + except ValueError, KeyError: data["protocol"] = None return Release(**data) @@ -75,11 +77,10 @@ def _dict_to_release(data: Dict[str, Any]) -> Release: def get_cached_results( provider: str, provider_id: str, - content_type: Optional[str] = None, - ttl_seconds: Optional[int] = None -) -> Optional[Dict[str, Any]]: - """ - Get cached search results for a book. + content_type: str | None = None, + ttl_seconds: int | None = None, +) -> dict[str, Any] | None: + """Get cached search results for a book. Args: provider: Metadata provider name (e.g., "hardcover", "openlibrary") @@ -90,6 +91,7 @@ def get_cached_results( Returns: Dict with 'releases' (List[Release]) and 'online_servers' (List[str]), or None if not cached or expired + """ from shelfmark.core.config import config @@ -100,7 +102,7 @@ def get_cached_results( # TTL of 0 means cache forever if ttl_seconds == 0: - ttl_seconds = float('inf') + ttl_seconds = float("inf") cache_key = _generate_cache_key(provider, provider_id, content_type) @@ -117,7 +119,12 @@ def get_cached_results( if age > ttl_seconds: title = entry.get("title", cache_key) - logger.debug(f"IRC cache expired for '{title}' (age: {age:.0f}s > TTL: {ttl_seconds}s)") + logger.debug( + "IRC cache expired for '%s' (age: %.0fs > TTL: %ss)", + title, + age, + ttl_seconds, + ) # Don't delete here - let cleanup handle it return None @@ -126,7 +133,12 @@ def get_cached_results( online_servers = entry.get("online_servers", []) title = entry.get("title", "") - logger.info(f"IRC cache hit for '{title}' ({len(releases)} releases, age: {age:.0f}s)") + logger.info( + "IRC cache hit for '%s' (%s releases, age: %.0fs)", + title, + len(releases), + age, + ) return { "releases": releases, @@ -139,12 +151,11 @@ def cache_results( provider: str, provider_id: str, title: str, - releases: List[Release], - content_type: Optional[str] = None, - online_servers: Optional[List[str]] = None + releases: list[Release], + content_type: str | None = None, + online_servers: list[str] | None = None, ) -> None: - """ - Cache search results for a book. + """Cache search results for a book. Args: provider: Metadata provider name @@ -153,6 +164,7 @@ def cache_results( releases: List of Release objects from search content_type: Search content type for cache isolation online_servers: List of online server nicks (optional) + """ cache_key = _generate_cache_key(provider, provider_id, content_type) @@ -173,12 +185,11 @@ def cache_results( } _save_cache(cache) - logger.info(f"Cached {len(releases)} IRC releases for '{title}'") + logger.info("Cached %s IRC releases for '%s'", len(releases), title) -def invalidate_cache(provider: str, provider_id: str, content_type: Optional[str] = None) -> bool: - """ - Remove a specific entry from the cache. +def invalidate_cache(provider: str, provider_id: str, content_type: str | None = None) -> bool: + """Remove a specific entry from the cache. Args: provider: Metadata provider name @@ -187,6 +198,7 @@ def invalidate_cache(provider: str, provider_id: str, content_type: Optional[str Returns: True if entry was found and removed + """ cache_key = _generate_cache_key(provider, provider_id, content_type) @@ -198,34 +210,34 @@ def invalidate_cache(provider: str, provider_id: str, content_type: Optional[str if cache_key in cache.get("entries", {}): del cache["entries"][cache_key] _save_cache(cache) - logger.info(f"Invalidated IRC cache for '{title}'") + logger.info("Invalidated IRC cache for '%s'", title) return True return False def clear_cache() -> int: - """ - Clear all cached entries. + """Clear all cached entries. Returns: Number of entries cleared + """ with _cache_lock: cache = _load_cache() count = len(cache.get("entries", {})) cache["entries"] = {} _save_cache(cache) - logger.info(f"Cleared {count} IRC cache entries") + logger.info("Cleared %s IRC cache entries", count) return count -def cleanup_expired(ttl_seconds: Optional[int] = None) -> int: - """ - Remove all expired entries from the cache. +def cleanup_expired(ttl_seconds: int | None = None) -> int: + """Remove all expired entries from the cache. Returns: Number of entries removed + """ from shelfmark.core.config import config @@ -242,7 +254,8 @@ def cleanup_expired(ttl_seconds: Optional[int] = None) -> int: entries = cache.get("entries", {}) expired_keys = [ - key for key, entry in entries.items() + key + for key, entry in entries.items() if current_time - entry.get("cached_at", 0) > ttl_seconds ] @@ -252,17 +265,17 @@ def cleanup_expired(ttl_seconds: Optional[int] = None) -> int: if removed: _save_cache(cache) - logger.info(f"Cleaned up {removed} expired IRC cache entries") + logger.info("Cleaned up %s expired IRC cache entries", removed) return removed -def get_cache_stats() -> Dict[str, Any]: - """ - Get cache statistics. +def get_cache_stats() -> dict[str, Any]: + """Get cache statistics. Returns: Dict with cache stats + """ from shelfmark.core.config import config @@ -277,15 +290,13 @@ def get_cache_stats() -> Dict[str, Any]: total = len(entries) expired = sum( - 1 for entry in entries.values() + 1 + for entry in entries.values() if current_time - entry.get("cached_at", 0) > ttl_seconds ) # Calculate total releases cached - total_releases = sum( - len(entry.get("releases", [])) - for entry in entries.values() - ) + total_releases = sum(len(entry.get("releases", [])) for entry in entries.values()) return { "total_entries": total, diff --git a/shelfmark/release_sources/irc/client.py b/shelfmark/release_sources/irc/client.py index 39af4f00..a85f8804 100644 --- a/shelfmark/release_sources/irc/client.py +++ b/shelfmark/release_sources/irc/client.py @@ -7,60 +7,63 @@ import re import socket import ssl import time +from contextlib import suppress from dataclasses import dataclass, field from enum import Enum, auto -from typing import Iterator, Optional +from typing import TYPE_CHECKING from shelfmark.core.logger import setup_logger from .dcc import DCCOffer, parse_dcc_send +if TYPE_CHECKING: + from collections.abc import Iterator + logger = setup_logger(__name__) # Timing -POST_CONNECT_DELAY = 2.0 # Seconds to wait after connect before joining -SOCKET_TIMEOUT = 300.0 # 5 minutes - long because we wait for DCC offers +SOCKET_TIMEOUT = 300.0 # 5 minutes - long because we wait for DCC offers RECV_BUFFER = 4096 # IRC channel user prefixes that indicate elevated status (ops, voice, etc.) # These are the download bots/servers -ELEVATED_PREFIXES = frozenset({'~', '&', '@', '%', '+'}) +ELEVATED_PREFIXES = frozenset({"~", "&", "@", "%", "+"}) class IRCEvent(Enum): """Events detected from IRC messages.""" - MESSAGE = auto() # Generic message - SEARCH_RESULT = auto() # DCC SEND with "_results_for" - BOOK_RESULT = auto() # DCC SEND for actual book - NO_RESULTS = auto() # "Sorry" notice - BAD_SERVER = auto() # "try another server" notice - SEARCH_ACCEPTED = auto() # "has been accepted" notice - MATCHES_FOUND = auto() # "X matches" notice - SERVER_LIST = auto() # User list (353/366) - PING = auto() # Server PING - VERSION = auto() # CTCP VERSION request + + MESSAGE = auto() # Generic message + SEARCH_RESULT = auto() # DCC SEND with "_results_for" + BOOK_RESULT = auto() # DCC SEND for actual book + NO_RESULTS = auto() # "Sorry" notice + BAD_SERVER = auto() # "try another server" notice + SEARCH_ACCEPTED = auto() # "has been accepted" notice + MATCHES_FOUND = auto() # "X matches" notice + SERVER_LIST = auto() # User list (353/366) + PING = auto() # Server PING + VERSION = auto() # CTCP VERSION request @dataclass class IRCMessage: """Parsed IRC message.""" + raw: str - prefix: Optional[str] = None + prefix: str | None = None command: str = "" params: list[str] = field(default_factory=list) - trailing: Optional[str] = None + trailing: str | None = None event: IRCEvent = IRCEvent.MESSAGE class IRCError(Exception): """Base IRC error.""" - pass class IRCConnectionError(IRCError): """Connection failed.""" - pass class IRCClient: @@ -71,22 +74,26 @@ class IRCClient: nick: str, server: str, port: int, + *, use_tls: bool = True, version: str = "Shelfmark 1.0", - ): + ) -> None: if not nick: - raise IRCError("IRC nickname is required") + msg = "IRC nickname is required" + raise IRCError(msg) if not server: - raise IRCError("IRC server is required") + msg = "IRC server is required" + raise IRCError(msg) if not port: - raise IRCError("IRC port is required") + msg = "IRC port is required" + raise IRCError(msg) self.nick = nick self.server = server self.port = port self.use_tls = use_tls self.version = version - self._socket: Optional[socket.socket] = None + self._socket: socket.socket | None = None self._buffer = "" self._connected = False @@ -95,7 +102,7 @@ class IRCClient: def connect(self) -> None: """Connect to IRC server, send USER/NICK, and wait for welcome.""" - logger.info(f"Connecting to {self.server}:{self.port} (TLS={self.use_tls})") + logger.info("Connecting to %s:%s (TLS=%s)", self.server, self.port, self.use_tls) try: # Create socket @@ -113,8 +120,9 @@ class IRCClient: sock.connect((self.server, self.port)) self._socket = sock - except socket.error as e: - raise IRCConnectionError(f"Failed to connect: {e}") + except OSError as e: + msg = f"Failed to connect: {e}" + raise IRCConnectionError(msg) from e # Send authentication (USER before NICK per IRC protocol) self._send(f"USER {self.nick} 0 * :{self.nick}") @@ -132,14 +140,15 @@ class IRCClient: try: data = self._socket.recv(RECV_BUFFER) if not data: - raise IRCConnectionError("Connection closed during registration") - self._buffer += data.decode('utf-8', errors='replace') - except socket.timeout: + msg = "Connection closed during registration" + raise IRCConnectionError(msg) + self._buffer += data.decode("utf-8", errors="replace") + except TimeoutError: continue # Process lines looking for 001 or errors - while '\r\n' in self._buffer: - line, self._buffer = self._buffer.split('\r\n', 1) + while "\r\n" in self._buffer: + line, self._buffer = self._buffer.split("\r\n", 1) if not line: continue @@ -147,45 +156,44 @@ class IRCClient: if line.startswith("PING"): pong = line.replace("PING", "PONG", 1) self._send(pong) - logger.debug(f"PONG {pong.split(':')[-1] if ':' in pong else ''}") + logger.debug("PONG %s", pong.split(":")[-1] if ":" in pong else "") continue # 001 = RPL_WELCOME - registration complete if " 001 " in line: self._socket.settimeout(SOCKET_TIMEOUT) # Restore timeout self._connected = True - logger.info(f"Connected as {self.nick}") + logger.info("Connected as %s", self.nick) return # Check for fatal errors if " 433 " in line: # Nickname in use - raise IRCConnectionError("Nickname already in use") + msg = "Nickname already in use" + raise IRCConnectionError(msg) if " 432 " in line: # Erroneous nickname - raise IRCConnectionError("Invalid nickname") + msg = "Invalid nickname" + raise IRCConnectionError(msg) - raise IRCConnectionError("Timeout waiting for server welcome") + msg = "Timeout waiting for server welcome" + raise IRCConnectionError(msg) def disconnect(self) -> None: """Gracefully disconnect from server.""" if self._socket: - try: + with suppress(Exception): self._send("QUIT :Goodbye") - except Exception: - pass # Best effort - try: + with suppress(Exception): self._socket.close() - except Exception: - pass self._socket = None self._connected = False logger.info("Disconnected from IRC") - def join_channel(self, channel: str, wait_for_join: bool = True) -> None: + def join_channel(self, channel: str, *, wait_for_join: bool = True) -> None: """Join an IRC channel (without # prefix) and capture online servers.""" self._send(f"JOIN #{channel}") - logger.debug(f"Sent JOIN #{channel}") + logger.debug("Sent JOIN #%s", channel) # Clear any existing server list before joining self.online_servers.clear() @@ -205,18 +213,18 @@ class IRCClient: data = self._socket.recv(RECV_BUFFER) if not data: break - self._buffer += data.decode('utf-8', errors='replace') - except socket.timeout: + self._buffer += data.decode("utf-8", errors="replace") + except TimeoutError: continue # No data yet, check time and retry # Process any complete lines in buffer - while '\r\n' in self._buffer: - line, self._buffer = self._buffer.split('\r\n', 1) + while "\r\n" in self._buffer: + line, self._buffer = self._buffer.split("\r\n", 1) if not line: continue msg = self._parse_message(line) - logger.debug(f"JOIN wait recv: {msg.command} - {line[:80]}") + logger.debug("JOIN wait recv: %s - %s", msg.command, line[:80]) # Handle PING during join wait if msg.event == IRCEvent.PING: @@ -230,15 +238,19 @@ class IRCClient: # 366 = RPL_ENDOFNAMES - channel join is complete if msg.command == "366": - logger.info(f"Joined #{channel} - {len(self.online_servers)} servers online") + logger.info( + "Joined #%s - %s servers online", + channel, + len(self.online_servers), + ) return # Check for errors (e.g., banned, channel doesn't exist) if msg.command in ("473", "474", "475", "403"): - logger.error(f"Cannot join #{channel}: {msg.trailing}") + logger.error("Cannot join #%s: %s", channel, msg.trailing) return - logger.warning(f"Timeout waiting for JOIN confirmation on #{channel}") + logger.warning("Timeout waiting for JOIN confirmation on #%s", channel) finally: # Restore original socket timeout @@ -247,23 +259,16 @@ class IRCClient: def send_message(self, target: str, message: str) -> None: """Send a PRIVMSG to a channel or user.""" self._send(f"PRIVMSG {target} :{message}") - logger.debug(f"Sent to {target}: {message[:50]}...") + logger.debug("Sent to %s: %s...", target, message[:50]) def send_notice(self, target: str, message: str) -> None: """Send a NOTICE to a user.""" self._send(f"NOTICE {target} :{message}") - def request_names(self, channel: str) -> None: - """Request user list for a channel (without # prefix).""" - self._send(f"NAMES #{channel}") - def _parse_names_list(self, names_data: str) -> None: """Parse 353 NAMES reply and extract elevated users (download servers).""" # Extract the trailing part after the last colon (the actual names) - if ' :' in names_data: - names_part = names_data.split(' :')[-1] - else: - names_part = names_data + names_part = names_data.rsplit(" :", maxsplit=1)[-1] if " :" in names_data else names_data for name in names_part.split(): # Check if user has an elevated prefix @@ -275,17 +280,18 @@ class IRCClient: def _send(self, message: str) -> None: """Send raw IRC message.""" if not self._socket: - raise IRCError("Not connected") + msg = "Not connected" + raise IRCError(msg) - data = f"{message}\r\n".encode('utf-8') + data = f"{message}\r\n".encode() self._socket.sendall(data) def _recv_lines(self) -> Iterator[str]: """Receive and yield complete CRLF-delimited IRC lines.""" while True: # Check if we have a complete line in buffer - while '\r\n' in self._buffer: - line, self._buffer = self._buffer.split('\r\n', 1) + while "\r\n" in self._buffer: + line, self._buffer = self._buffer.split("\r\n", 1) if line: yield line @@ -294,11 +300,11 @@ class IRCClient: data = self._socket.recv(RECV_BUFFER) if not data: return # Connection closed - self._buffer += data.decode('utf-8', errors='replace') - except socket.timeout: + self._buffer += data.decode("utf-8", errors="replace") + except TimeoutError: continue # Keep waiting - except socket.error as e: - logger.warning(f"Socket error: {e}") + except OSError as e: + logger.warning("Socket error: %s", e) return # Connection error def _parse_message(self, line: str) -> IRCMessage: @@ -309,16 +315,16 @@ class IRCClient: msg = IRCMessage(raw=line) # Extract prefix if present - if line.startswith(':'): - space_idx = line.find(' ') + if line.startswith(":"): + space_idx = line.find(" ") if space_idx != -1: msg.prefix = line[1:space_idx] - line = line[space_idx + 1:] + line = line[space_idx + 1 :] # Extract trailing if present - if ' :' in line: - idx = line.find(' :') - msg.trailing = line[idx + 2:] + if " :" in line: + idx = line.find(" :") + msg.trailing = line[idx + 2 :] line = line[:idx] # Split remaining into command and params @@ -373,17 +379,17 @@ class IRCClient: # PING message format: PING :server server = msg.trailing or self.server self._send(f"PONG :{server}") - logger.debug(f"PONG {server}") + logger.debug("PONG %s", server) def _handle_version(self, msg: IRCMessage) -> None: """Respond to CTCP VERSION request.""" if msg.prefix: # Extract nick from prefix (nick!user@host) - sender = msg.prefix.split('!')[0] + sender = msg.prefix.split("!")[0] self.send_notice(sender, f"\x01VERSION {self.version}\x01") - logger.debug(f"Sent VERSION to {sender}") + logger.debug("Sent VERSION to %s", sender) - def read_messages(self, auto_handle: bool = True) -> Iterator[IRCMessage]: + def read_messages(self, *, auto_handle: bool = True) -> Iterator[IRCMessage]: """Read and yield IRC messages, optionally auto-handling PING/VERSION.""" for line in self._recv_lines(): msg = self._parse_message(line) @@ -403,8 +409,9 @@ class IRCClient: def wait_for_dcc( self, timeout: float = 60.0, + *, result_type: bool = False, - ) -> Optional[DCCOffer]: + ) -> DCCOffer | None: """Wait for a DCC SEND offer. Returns None on timeout or no results.""" target_event = IRCEvent.SEARCH_RESULT if result_type else IRCEvent.BOOK_RESULT start = time.time() @@ -417,31 +424,30 @@ class IRCClient: if msg.event == target_event: try: offer = parse_dcc_send(msg.raw) - logger.info(f"Received DCC offer: {offer.filename}") - return offer - except Exception as e: - logger.error(f"Failed to parse DCC: {e}") + logger.info("Received DCC offer: %s", offer.filename) + except Exception: + logger.exception("Failed to parse DCC") return None + else: + return offer # Log other events for debugging if msg.event == IRCEvent.NO_RESULTS: logger.info("Server reports no results") return None - elif msg.event == IRCEvent.BAD_SERVER: + if msg.event == IRCEvent.BAD_SERVER: logger.warning("Server unavailable") return None - elif msg.event == IRCEvent.SEARCH_ACCEPTED: + if msg.event == IRCEvent.SEARCH_ACCEPTED: logger.info("Search accepted, waiting for results...") - elif msg.event == IRCEvent.MATCHES_FOUND: + elif ( + msg.event == IRCEvent.MATCHES_FOUND and msg.trailing and "returned" in msg.trailing + ): # Extract count from "returned X matches" - if msg.trailing and "returned" in msg.trailing: - try: - match = re.search(r'returned\s+(\d+)\s+matches', msg.trailing) - if match: - count = match.group(1) - logger.info(f"Found {count} matches") - except Exception: - pass + match = re.search(r"returned\s+(\d+)\s+matches", msg.trailing) + if match: + count = match.group(1) + logger.info("Found %s matches", count) return None @@ -450,9 +456,9 @@ class IRCClient: """Check if currently connected.""" return self._connected and self._socket is not None - def __enter__(self): + def __enter__(self) -> IRCClient: self.connect() return self - def __exit__(self, *args): + def __exit__(self, *args) -> None: self.disconnect() diff --git a/shelfmark/release_sources/irc/connection_manager.py b/shelfmark/release_sources/irc/connection_manager.py index 8c756aaa..3a5d199a 100644 --- a/shelfmark/release_sources/irc/connection_manager.py +++ b/shelfmark/release_sources/irc/connection_manager.py @@ -5,7 +5,7 @@ Maintains persistent IRC connections to avoid reconnecting between search and do import threading import time -from typing import Optional +from contextlib import suppress from shelfmark.core.logger import setup_logger @@ -25,10 +25,10 @@ class IRCConnectionManager: being idle for IDLE_TIMEOUT seconds. """ - _instance: Optional["IRCConnectionManager"] = None + _instance: IRCConnectionManager | None = None _lock = threading.Lock() - def __new__(cls) -> "IRCConnectionManager": + def __new__(cls) -> IRCConnectionManager: """Singleton pattern - only one connection manager.""" if cls._instance is None: with cls._lock: @@ -37,7 +37,7 @@ class IRCConnectionManager: cls._instance._initialized = False return cls._instance - def __init__(self): + def __init__(self) -> None: if self._initialized: return @@ -46,7 +46,7 @@ class IRCConnectionManager: self._channels: dict[str, str] = {} # connection_key -> joined channel self._connecting: dict[str, bool] = {} # Track keys currently being connected self._conn_lock = threading.Lock() - self._cleanup_thread: Optional[threading.Thread] = None + self._cleanup_thread: threading.Thread | None = None self._running = True self._initialized = True @@ -59,7 +59,8 @@ class IRCConnectionManager: def _start_cleanup_thread(self) -> None: """Start background thread to clean up idle connections.""" - def cleanup_loop(): + + def cleanup_loop() -> None: while self._running: time.sleep(30) # Check every 30 seconds self._cleanup_idle_connections() @@ -83,17 +84,18 @@ class IRCConnectionManager: self._channels.pop(key, None) if client: - logger.info(f"Closing idle IRC connection: {key}") + logger.info("Closing idle IRC connection: %s", key) try: client.disconnect() except Exception as e: - logger.debug(f"Error closing idle connection: {e}") + logger.debug("Error closing idle connection: %s", e) def get_connection( self, server: str, port: int, nick: str, + *, use_tls: bool, channel: str, ) -> IRCClient: @@ -111,6 +113,7 @@ class IRCConnectionManager: Returns: Connected IRCClient instance that has joined the channel + """ key = self._connection_key(server, port, nick) need_new_connection = False @@ -121,13 +124,13 @@ class IRCConnectionManager: existing = self._connections.get(key) if existing and existing.is_connected: - logger.info(f"Reusing existing IRC connection to {server}") + logger.info("Reusing existing IRC connection to %s", server) self._last_used[key] = time.time() # Check if we need to join a different channel current_channel = self._channels.get(key) if current_channel != channel: - logger.debug(f"Joining channel #{channel}") + logger.debug("Joining channel #%s", channel) existing.join_channel(channel) self._channels[key] = channel @@ -135,13 +138,13 @@ class IRCConnectionManager: # Check if another thread is already connecting if self._connecting.get(key): - logger.debug(f"Another thread is connecting to {key}, waiting...") + logger.debug("Another thread is connecting to %s, waiting...", key) # Release lock and wait, then retry - pass # Fall through to retry logic below + # Fall through to retry logic below else: # Clean up dead connection if it exists if existing: - logger.debug(f"Removing dead connection: {key}") + logger.debug("Removing dead connection: %s", key) self._connections.pop(key, None) self._last_used.pop(key, None) self._channels.pop(key, None) @@ -153,19 +156,23 @@ class IRCConnectionManager: # Clean up dead client outside lock if dead_client: - try: + with suppress(Exception): dead_client.disconnect() - except Exception: - pass # If another thread is connecting, wait and retry if not need_new_connection: time.sleep(0.5) - return self.get_connection(server, port, nick, use_tls, channel) + return self.get_connection( + server=server, + port=port, + nick=nick, + use_tls=use_tls, + channel=channel, + ) # Create new connection OUTSIDE the lock to avoid blocking other threads try: - logger.info(f"Creating new IRC connection to {server}:{port}") + logger.info("Creating new IRC connection to %s:%s", server, port) client = IRCClient(nick, server, port, use_tls=use_tls) client.connect() client.join_channel(channel) @@ -176,14 +183,15 @@ class IRCConnectionManager: self._last_used[key] = time.time() self._channels[key] = channel self._connecting.pop(key, None) - - return client except Exception: # Clear connecting flag on failure with self._conn_lock: self._connecting.pop(key, None) raise + else: + return client + def release_connection(self, client: IRCClient) -> None: """Mark a connection as available for reuse. @@ -195,7 +203,7 @@ class IRCConnectionManager: with self._conn_lock: if key in self._connections: self._last_used[key] = time.time() - logger.debug(f"Released IRC connection for reuse: {key}") + logger.debug("Released IRC connection for reuse: %s", key) def close_connection(self, client: IRCClient) -> None: """Explicitly close a connection (e.g., on error). @@ -213,18 +221,15 @@ class IRCConnectionManager: try: client.disconnect() except Exception as e: - logger.debug(f"Error closing connection: {e}") + logger.debug("Error closing connection: %s", e) - logger.debug(f"Closed IRC connection: {key}") + logger.debug("Closed IRC connection: %s", key) def close_all(self) -> None: """Close all connections (for shutdown).""" with self._conn_lock: for key, client in list(self._connections.items()): - try: - client.disconnect() - except Exception as e: - logger.debug(f"Error closing connection {key}: {e}") + self._close_connection(client, key) self._connections.clear() self._last_used.clear() @@ -232,6 +237,14 @@ class IRCConnectionManager: logger.info("Closed all IRC connections") + @staticmethod + def _close_connection(client: IRCClient, key: str) -> None: + """Disconnect one IRC client and log failures.""" + try: + client.disconnect() + except Exception as e: + logger.debug("Error closing connection %s: %s", key, e) + # Global singleton instance connection_manager = IRCConnectionManager() diff --git a/shelfmark/release_sources/irc/dcc.py b/shelfmark/release_sources/irc/dcc.py index b8009440..4e979b09 100644 --- a/shelfmark/release_sources/irc/dcc.py +++ b/shelfmark/release_sources/irc/dcc.py @@ -7,12 +7,15 @@ import re import socket import struct from dataclasses import dataclass -from pathlib import Path -from threading import Event -from typing import Callable, Optional +from typing import TYPE_CHECKING from shelfmark.core.logger import setup_logger +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + from threading import Event + logger = setup_logger(__name__) # Regex to parse DCC SEND messages - handles quoted filenames @@ -28,6 +31,7 @@ BUFFER_SIZE = 4096 @dataclass class DCCOffer: """Parsed DCC SEND offer.""" + filename: str ip: str port: int @@ -41,35 +45,32 @@ class DCCOffer: class DCCError(Exception): """Base exception for DCC operations.""" - pass class DCCParseError(DCCError): """Failed to parse DCC SEND string.""" - pass class DCCSizeError(DCCError): """Downloaded size doesn't match expected size.""" - pass class DCCConnectionError(DCCError): """Failed to connect to DCC sender.""" - pass def int_to_ip(ip_int: int) -> str: """Convert 32-bit integer (DCC format) to dotted IP notation.""" - packed = struct.pack('>I', ip_int) - return '.'.join(str(b) for b in packed) + packed = struct.pack(">I", ip_int) + return ".".join(str(b) for b in packed) def parse_dcc_send(text: str) -> DCCOffer: """Parse a DCC SEND message into a DCCOffer. Raises DCCParseError on failure.""" match = DCC_REGEX.search(text) if not match: - raise DCCParseError(f"Invalid DCC SEND format: {text[:100]}") + msg = f"Invalid DCC SEND format: {text[:100]}" + raise DCCParseError(msg) filename = match.group(1).strip('"') ip_int = int(match.group(2)) @@ -87,25 +88,26 @@ def parse_dcc_send(text: str) -> DCCOffer: def download_dcc( offer: DCCOffer, dest_path: Path, - progress_callback: Optional[Callable[[float], None]] = None, - cancel_flag: Optional[Event] = None, + progress_callback: Callable[[float], None] | None = None, + cancel_flag: Event | None = None, timeout: float = 30.0, ) -> None: """Download file via DCC protocol to dest_path. Raises DCCError on failure.""" - logger.info(f"DCC connecting to {offer.ip}:{offer.port} for {offer.filename}") + logger.info("DCC connecting to %s:%s for %s", offer.ip, offer.port, offer.filename) try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) sock.connect(offer.address) - except socket.error as e: - raise DCCConnectionError(f"Failed to connect to {offer.ip}:{offer.port}: {e}") + except OSError as e: + msg = f"Failed to connect to {offer.ip}:{offer.port}: {e}" + raise DCCConnectionError(msg) from e try: received = 0 last_progress = -1 - with open(dest_path, 'wb') as f: + with dest_path.open("wb") as f: while received < offer.size: # Check for cancellation if cancel_flag and cancel_flag.is_set(): @@ -115,8 +117,9 @@ def download_dcc( # Read chunk try: chunk = sock.recv(BUFFER_SIZE) - except socket.timeout: - raise DCCError(f"Timeout reading from {offer.ip}:{offer.port}") + except TimeoutError as e: + msg = f"Timeout reading from {offer.ip}:{offer.port}" + raise DCCError(msg) from e if not chunk: # Connection closed prematurely @@ -134,11 +137,10 @@ def download_dcc( # Verify downloaded size matches expected if received != offer.size: - raise DCCSizeError( - f"Size mismatch: expected {offer.size} bytes, got {received}" - ) + msg = f"Size mismatch: expected {offer.size} bytes, got {received}" + raise DCCSizeError(msg) - logger.info(f"DCC download complete: {received} bytes") + logger.info("DCC download complete: %s bytes", received) finally: sock.close() diff --git a/shelfmark/release_sources/irc/handler.py b/shelfmark/release_sources/irc/handler.py index d32c3c53..dad4c85f 100644 --- a/shelfmark/release_sources/irc/handler.py +++ b/shelfmark/release_sources/irc/handler.py @@ -4,17 +4,21 @@ Handles downloading IRC releases via DCC protocol. """ from pathlib import Path -from threading import Event -from typing import Callable, Optional +from typing import TYPE_CHECKING from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.release_sources import DownloadHandler, register_handler from .connection_manager import connection_manager from .dcc import DCCError, download_dcc +if TYPE_CHECKING: + from collections.abc import Callable + from threading import Event + + from shelfmark.core.models import DownloadTask + logger = setup_logger(__name__) @@ -27,11 +31,11 @@ class IRCDownloadHandler(DownloadHandler): task: DownloadTask, cancel_flag: Event, progress_callback: Callable[[float], None], - status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[str]: + status_callback: Callable[[str, str | None], None], + ) -> str | None: """Download a release via IRC DCC. task.task_id contains the IRC request string.""" download_request = task.task_id - logger.info(f"IRC download: {download_request[:60]}...") + logger.info("IRC download: %s...", download_request[:60]) # Get IRC settings server = config.get("IRC_SERVER", "") @@ -97,10 +101,11 @@ class IRCDownloadHandler(DownloadHandler): status_callback("downloading", "") # Get file extension from offer filename - ext = Path(offer.filename).suffix.lstrip('.') or task.format or "epub" + ext = Path(offer.filename).suffix.lstrip(".") or task.format or "epub" # Stage to temp directory (lazy import to avoid circular import) from shelfmark.download.staging import get_staging_path + staging_path = get_staging_path(task.task_id, ext) download_dcc( @@ -120,18 +125,18 @@ class IRCDownloadHandler(DownloadHandler): status_callback("cancelled", "Cancelled") return None - logger.info(f"Download complete: {staging_path}") + logger.info("Download complete: %s", staging_path) return str(staging_path) except DCCError as e: - logger.error(f"DCC error: {e}") + logger.exception("DCC error") status_callback("error", str(e)) if client: connection_manager.close_connection(client) return None except Exception as e: - logger.error(f"Download failed: {e}") + logger.exception("Download failed") status_callback("error", f"Download failed: {e}") if client: connection_manager.close_connection(client) @@ -139,5 +144,5 @@ class IRCDownloadHandler(DownloadHandler): def cancel(self, task_id: str) -> bool: """Cancel an in-progress download (cleanup if cancel_flag fails).""" - logger.debug(f"Cancel requested for IRC task: {task_id}") + logger.debug("Cancel requested for IRC task: %s", task_id) return True diff --git a/shelfmark/release_sources/irc/parser.py b/shelfmark/release_sources/irc/parser.py index 7493392c..ca77b148 100644 --- a/shelfmark/release_sources/irc/parser.py +++ b/shelfmark/release_sources/irc/parser.py @@ -6,13 +6,15 @@ Parses the text files sent via DCC that contain search results. import re import zipfile from dataclasses import dataclass -from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import is_audiobook as check_audiobook +if TYPE_CHECKING: + from pathlib import Path + logger = setup_logger(__name__) # All recognized formats for parsing IRC result lines. @@ -20,51 +22,80 @@ logger = setup_logger(__name__) # User-configured formats are used separately for filtering. ALL_RECOGNIZED_FORMATS = { # Ebook formats - 'epub', 'mobi', 'azw3', 'azw', 'pdf', 'doc', 'docx', - 'html', 'htm', 'rtf', 'txt', 'lit', 'fb2', 'djvu', - 'cbr', 'cbz', 'cdr', 'jpg', 'rar', 'zip', + "epub", + "mobi", + "azw3", + "azw", + "pdf", + "doc", + "docx", + "html", + "htm", + "rtf", + "txt", + "lit", + "fb2", + "djvu", + "cbr", + "cbz", + "cdr", + "jpg", + "rar", + "zip", # Audiobook formats - 'm4b', 'mp3', 'm4a', 'flac', 'ogg', 'wma', 'aac', 'wav', 'opus' + "m4b", + "mp3", + "m4a", + "flac", + "ogg", + "wma", + "aac", + "wav", + "opus", } -def _get_supported_formats(content_type: Optional[str] = None) -> set[str]: +def _get_supported_formats(content_type: str | None = None) -> set[str]: """Get the supported formats for the requested content type.""" if check_audiobook(content_type): formats = config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"]) else: - formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"]) + formats = config.get( + "SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"] + ) if isinstance(formats, str): return {fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()} return {fmt.lower() for fmt in formats} + # Regex to parse result lines # Format: !Server Author - Title.format ::INFO:: size RESULT_LINE_REGEX = re.compile( - r'^!(\S+)\s+' # !ServerName - r'(.+?)\s+-\s+' # Author Name - - r'(.+?)\.(\w+)' # Title.format - r'(?:\s+::INFO::\s*(.+?))?' # Optional ::INFO:: metadata - r'(?:\s+::HASH::\s*(\S+))?' # Optional ::HASH:: - r'\s*$' + r"^!(\S+)\s+" # !ServerName + r"(.+?)\s+-\s+" # Author Name - + r"(.+?)\.(\w+)" # Title.format + r"(?:\s+::INFO::\s*(.+?))?" # Optional ::INFO:: metadata + r"(?:\s+::HASH::\s*(\S+))?" # Optional ::HASH:: + r"\s*$" ) # Simpler fallback pattern SIMPLE_RESULT_REGEX = re.compile( - r'^!(\S+)\s+(.+)$' # !Server everything_else + r"^!(\S+)\s+(.+)$" # !Server everything_else ) @dataclass class SearchResult: """Parsed search result entry.""" - server: str # Bot name (without !) - author: str # Author name - title: str # Book title - format: str # File format (epub, mobi, etc) - size: Optional[str] # Human-readable size - full_line: str # Original line for download request + + server: str # Bot name (without !) + author: str # Author name + title: str # Book title + format: str # File format (epub, mobi, etc) + size: str | None # Human-readable size + full_line: str # Original line for download request @property def download_request(self) -> str: @@ -77,12 +108,12 @@ class SearchResult: return f"{self.author} - {self.title}" -def parse_result_line(line: str) -> Optional[SearchResult]: +def parse_result_line(line: str) -> SearchResult | None: """Parse a single search result line. Returns None if unparseable.""" line = line.strip() # Must start with ! - if not line.startswith('!'): + if not line.startswith("!"): return None # Try detailed pattern first @@ -106,13 +137,13 @@ def parse_result_line(line: str) -> Optional[SearchResult]: # Try to extract format from the line fmt = None for known_fmt in ALL_RECOGNIZED_FORMATS: - if f'.{known_fmt}' in rest.lower(): + if f".{known_fmt}" in rest.lower(): fmt = known_fmt break # Try to split author - title - if ' - ' in rest: - parts = rest.split(' - ', 1) + if " - " in rest: + parts = rest.split(" - ", 1) author = parts[0].strip() title_part = parts[1].strip() if len(parts) > 1 else rest else: @@ -121,56 +152,56 @@ def parse_result_line(line: str) -> Optional[SearchResult]: # Extract size if present size = None - if '::INFO::' in title_part: - title_part, info = title_part.split('::INFO::', 1) - size = info.split('::')[0].strip() + if "::INFO::" in title_part: + title_part, info = title_part.split("::INFO::", 1) + size = info.split("::")[0].strip() # Clean up title (remove extension) title = title_part for known_fmt in ALL_RECOGNIZED_FORMATS: - title = re.sub(rf'\.{known_fmt}\b', '', title, flags=re.IGNORECASE) + title = re.sub(rf"\.{known_fmt}\b", "", title, flags=re.IGNORECASE) return SearchResult( server=server, author=author, title=title.strip(), - format=fmt or 'unknown', + format=fmt or "unknown", size=size, full_line=line, ) - logger.debug(f"Could not parse line: {line[:80]}...") + logger.debug("Could not parse line: %s...", line[:80]) return None -def parse_results_file(content: str, content_type: Optional[str] = None) -> list[SearchResult]: +def parse_results_file(content: str, content_type: str | None = None) -> list[SearchResult]: """Parse a search results file into SearchResult objects.""" results = [] supported = _get_supported_formats(content_type) for line in content.splitlines(): result = parse_result_line(line) - if result: + if result and (result.format in supported or result.format == "unknown"): # Filter to user's configured formats - if result.format in supported or result.format == 'unknown': - results.append(result) + results.append(result) - logger.info(f"Parsed {len(results)} results from search file") + logger.info("Parsed %s results from search file", len(results)) return results def extract_results_from_zip(zip_path: Path) -> str: """Extract and return text content from a search results ZIP.""" - with zipfile.ZipFile(zip_path, 'r') as zf: + with zipfile.ZipFile(zip_path, "r") as zf: # Should contain exactly one text file names = zf.namelist() if not names: - raise ValueError("Empty ZIP file") + msg = "Empty ZIP file" + raise ValueError(msg) # Find the text file txt_file = None for name in names: - if name.endswith('.txt'): + if name.endswith(".txt"): txt_file = name break @@ -181,11 +212,18 @@ def extract_results_from_zip(zip_path: Path) -> str: content = zf.read(txt_file) # Try different encodings - for encoding in ['utf-8', 'latin-1', 'cp1252']: - try: - return content.decode(encoding) - except UnicodeDecodeError: - continue + for encoding in ["utf-8", "latin-1", "cp1252"]: + decoded = _decode_content_with_encoding(content, encoding) + if decoded is not None: + return decoded # Last resort - return content.decode('utf-8', errors='replace') + return content.decode("utf-8", errors="replace") + + +def _decode_content_with_encoding(content: bytes, encoding: str) -> str | None: + """Decode bytes using one encoding, returning None when it fails.""" + try: + return content.decode(encoding) + except UnicodeDecodeError: + return None diff --git a/shelfmark/release_sources/irc/settings.py b/shelfmark/release_sources/irc/settings.py index 27cb3f34..2203c7b6 100644 --- a/shelfmark/release_sources/irc/settings.py +++ b/shelfmark/release_sources/irc/settings.py @@ -9,12 +9,13 @@ from shelfmark.core.settings_registry import ( HeadingField, NumberField, SelectField, + SettingsField, TextField, register_settings, ) -def _clear_irc_cache(): +def _clear_irc_cache() -> dict[str, str | int | bool]: """Clear all cached IRC search results.""" from shelfmark.release_sources.irc.cache import clear_cache, get_cache_stats @@ -32,7 +33,7 @@ def _clear_irc_cache(): icon="download", order=56, ) -def irc_settings(): +def irc_settings() -> list[SettingsField]: """Define IRC source settings.""" return [ HeadingField( @@ -46,7 +47,6 @@ def irc_settings(): "which may not work behind strict firewalls or NAT." ), ), - TextField( key="IRC_SERVER", label="Server", @@ -55,7 +55,6 @@ def irc_settings(): required=True, env_supported=True, ), - NumberField( key="IRC_PORT", label="Port", @@ -63,15 +62,12 @@ def irc_settings(): description="IRC server port (usually 6697 for TLS, 6667 for plain)", env_supported=True, ), - CheckboxField( key="IRC_USE_TLS", label="Use TLS", default=True, description="Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't support TLS.", - env_supported=True, ), - TextField( key="IRC_CHANNEL", label="Channel", @@ -80,7 +76,6 @@ def irc_settings(): required=True, env_supported=True, ), - TextField( key="IRC_NICK", label="Nickname", @@ -89,7 +84,6 @@ def irc_settings(): required=True, env_supported=True, ), - TextField( key="IRC_SEARCH_BOT", label="Search bot", @@ -97,7 +91,6 @@ def irc_settings(): description="The search bot to query for results", env_supported=True, ), - HeadingField( key="cache_heading", title="Search Cache", @@ -106,7 +99,6 @@ def irc_settings(): "Use the Refresh button in the release modal to force a new search." ), ), - SelectField( key="IRC_CACHE_TTL", label="Cache Duration", @@ -117,7 +109,6 @@ def irc_settings(): ], default="2592000", # 30 days ), - ActionButton( key="clear_irc_cache", label="Clear Cache", diff --git a/shelfmark/release_sources/irc/source.py b/shelfmark/release_sources/irc/source.py index 112b69a5..0d492dc3 100644 --- a/shelfmark/release_sources/irc/source.py +++ b/shelfmark/release_sources/irc/source.py @@ -6,15 +6,15 @@ Searches IRC channels for ebook and audiobook releases. import tempfile import time from pathlib import Path -from typing import List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar if TYPE_CHECKING: from shelfmark.core.search_plan import ReleaseSearchPlan + from shelfmark.metadata_providers import BookMetadata from shelfmark.api.websocket import ws_manager from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.metadata_providers import BookMetadata from shelfmark.release_sources import ( ColumnColorHint, ColumnRenderType, @@ -36,16 +36,17 @@ from .parser import SearchResult, extract_results_from_zip, parse_results_file logger = setup_logger(__name__) -def _emit_status(message: str, phase: str = 'searching') -> None: +def _emit_status(message: str, phase: str = "searching") -> None: """Emit search status to frontend via WebSocket.""" ws_manager.broadcast_search_status( - source='irc', - provider='', - book_id='', + source="irc", + provider="", + book_id="", message=message, phase=phase, ) + # Rate limiting to avoid server throttling MIN_SEARCH_INTERVAL = 15.0 _last_search_time: float = 0 @@ -58,7 +59,7 @@ def _enforce_rate_limit() -> None: elapsed = time.time() - _last_search_time if elapsed < MIN_SEARCH_INTERVAL: wait_time = MIN_SEARCH_INTERVAL - elapsed - logger.info(f"Rate limiting: waiting {wait_time:.1f}s") + logger.info("Rate limiting: waiting %.1fs", wait_time) time.sleep(wait_time) _last_search_time = time.time() @@ -70,12 +71,12 @@ class IRCReleaseSource(ReleaseSource): name = "irc" display_name = "IRC" - supported_content_types = ["ebook", "audiobook"] + supported_content_types: ClassVar[list[str]] = ["ebook", "audiobook"] can_be_default = False # Exclude from default source options (requires deliberate selection) - def __init__(self): + def __init__(self) -> None: # Track online servers from most recent search - self._online_servers: Optional[set[str]] = None + self._online_servers: set[str] | None = None def is_available(self) -> bool: """Check if IRC is configured (server, channel, and nick are set).""" @@ -124,16 +125,17 @@ class IRCReleaseSource(ReleaseSource): def search( self, book: BookMetadata, - plan: "ReleaseSearchPlan", + plan: ReleaseSearchPlan, + *, expand_search: bool = False, - content_type: str = "ebook" - ) -> List[Release]: + content_type: str = "ebook", + ) -> list[Release]: """Search IRC for books matching metadata. The expand_search parameter is repurposed for IRC as a "refresh" flag. When True, it bypasses the cache and forces a fresh search. """ - from .cache import get_cached_results, cache_results + from .cache import cache_results, get_cached_results if not self.is_available(): logger.debug("IRC source is disabled, skipping search") @@ -143,7 +145,7 @@ class IRCReleaseSource(ReleaseSource): if not expand_search: cached = get_cached_results(book.provider, book.provider_id, content_type=content_type) if cached: - _emit_status("Using cached results", phase='complete') + _emit_status("Using cached results", phase="complete") self._online_servers = set(cached.get("online_servers", [])) return cached["releases"] @@ -153,7 +155,7 @@ class IRCReleaseSource(ReleaseSource): logger.warning("No search query could be built") return [] - logger.info(f"IRC search: {query}") + logger.info("IRC search: %s", query) # Enforce rate limit _enforce_rate_limit() @@ -169,7 +171,7 @@ class IRCReleaseSource(ReleaseSource): client = None try: # Get or reuse IRC connection - _emit_status(f"Connecting to {server}...", phase='connecting') + _emit_status(f"Connecting to {server}...", phase="connecting") client = connection_manager.get_connection( server=server, port=port, @@ -186,11 +188,11 @@ class IRCReleaseSource(ReleaseSource): client.send_message(f"#{channel}", search_msg) # Wait for results DCC - this is the long wait - _emit_status(f"Connected to #{channel} - Waiting for results...", phase='searching') + _emit_status(f"Connected to #{channel} - Waiting for results...", phase="searching") offer = client.wait_for_dcc(timeout=60.0, result_type=True) if not offer: logger.info("No search results received") - _emit_status("No results found", phase='complete') + _emit_status("No results found", phase="complete") # Release connection for reuse (don't close it) connection_manager.release_connection(client) # Cache empty result to avoid repeated failed searches @@ -205,16 +207,16 @@ class IRCReleaseSource(ReleaseSource): return [] # Download results file - _emit_status(f"Connected to #{channel} - Downloading results...", phase='downloading') + _emit_status(f"Connected to #{channel} - Downloading results...", phase="downloading") with tempfile.TemporaryDirectory() as tmpdir: result_path = Path(tmpdir) / offer.filename download_dcc(offer, result_path, timeout=30.0) # Parse results - if result_path.suffix.lower() == '.zip': + if result_path.suffix.lower() == ".zip": content = extract_results_from_zip(result_path) else: - content = result_path.read_text(errors='replace') + content = result_path.read_text(errors="replace") # Release connection for reuse (don't close it) connection_manager.release_connection(client) @@ -233,21 +235,22 @@ class IRCReleaseSource(ReleaseSource): online_servers=list(self._online_servers) if self._online_servers else None, ) - return releases - except DCCError as e: - logger.error(f"DCC error during search: {e}") - _emit_status(f"DCC error: {e}", phase='error') + logger.exception("DCC error during search") + _emit_status(f"DCC error: {e}", phase="error") if client: connection_manager.close_connection(client) return [] except Exception as e: - logger.error(f"IRC search failed: {e}") - _emit_status(f"Search failed: {e}", phase='error') + logger.exception("IRC search failed") + _emit_status(f"Search failed: {e}", phase="error") if client: connection_manager.close_connection(client) return [] + else: + return releases + def _build_query(self, book: BookMetadata) -> str: """Build search query from book metadata.""" parts = [] @@ -262,51 +265,51 @@ class IRCReleaseSource(ReleaseSource): author = book.authors[0] if isinstance(book.authors, list) else book.authors parts.append(author) - return ' '.join(parts) + return " ".join(parts) # Format priority for sorting (lower = higher priority) - EBOOK_FORMAT_PRIORITY = { - 'epub': 0, - 'mobi': 1, - 'azw3': 2, - 'azw': 3, - 'fb2': 4, - 'djvu': 5, - 'pdf': 6, - 'cbr': 7, - 'cbz': 8, - 'doc': 9, - 'docx': 10, - 'rtf': 11, - 'txt': 12, - 'html': 13, - 'htm': 14, - 'rar': 15, - 'zip': 16, + EBOOK_FORMAT_PRIORITY: ClassVar[dict[str, int]] = { + "epub": 0, + "mobi": 1, + "azw3": 2, + "azw": 3, + "fb2": 4, + "djvu": 5, + "pdf": 6, + "cbr": 7, + "cbz": 8, + "doc": 9, + "docx": 10, + "rtf": 11, + "txt": 12, + "html": 13, + "htm": 14, + "rar": 15, + "zip": 16, } - AUDIOBOOK_FORMAT_PRIORITY = { - 'm4b': 0, - 'mp3': 1, - 'm4a': 2, - 'flac': 3, - 'opus': 4, - 'ogg': 5, - 'aac': 6, - 'wav': 7, - 'wma': 8, - 'rar': 9, - 'zip': 10, + AUDIOBOOK_FORMAT_PRIORITY: ClassVar[dict[str, int]] = { + "m4b": 0, + "mp3": 1, + "m4a": 2, + "flac": 3, + "opus": 4, + "ogg": 5, + "aac": 6, + "wav": 7, + "wma": 8, + "rar": 9, + "zip": 10, } def _convert_to_releases( self, - results: List[SearchResult], + results: list[SearchResult], content_type: str = "ebook", - ) -> List[Release]: + ) -> list[Release]: """Convert parsed results to Release objects, sorted by online/format/server.""" releases = [] - online_servers = self._online_servers if self._online_servers else set() + online_servers = self._online_servers or set() format_priority_map = ( self.AUDIOBOOK_FORMAT_PRIORITY if content_type == "audiobook" @@ -340,8 +343,8 @@ class IRCReleaseSource(ReleaseSource): format_priority = format_priority_map.get(fmt, 99) return ( 0 if is_online else 1, # Online first - format_priority, # Then by format - server.lower(), # Then alphabetically by server + format_priority, # Then by format + server.lower(), # Then alphabetically by server ) releases.sort(key=sort_key) @@ -349,7 +352,7 @@ class IRCReleaseSource(ReleaseSource): return releases @staticmethod - def _parse_size(size_str: str) -> Optional[int]: + def _parse_size(size_str: str) -> int | None: """Parse human-readable size (e.g., '1.2MB', '500K') to bytes.""" if not size_str: return None @@ -358,19 +361,19 @@ class IRCReleaseSource(ReleaseSource): # Map suffixes to multipliers (check longer suffixes first) multipliers = [ - ('GB', 1024 * 1024 * 1024), - ('MB', 1024 * 1024), - ('KB', 1024), - ('G', 1024 * 1024 * 1024), - ('M', 1024 * 1024), - ('K', 1024), - ('B', 1), + ("GB", 1024 * 1024 * 1024), + ("MB", 1024 * 1024), + ("KB", 1024), + ("G", 1024 * 1024 * 1024), + ("M", 1024 * 1024), + ("K", 1024), + ("B", 1), ] for suffix, mult in multipliers: if size_str.endswith(suffix): try: - num = float(size_str[:-len(suffix)].strip()) + num = float(size_str[: -len(suffix)].strip()) return int(num * mult) except ValueError: return None diff --git a/shelfmark/release_sources/prowlarr/__init__.py b/shelfmark/release_sources/prowlarr/__init__.py index 63afe8c4..19a7ac78 100644 --- a/shelfmark/release_sources/prowlarr/__init__.py +++ b/shelfmark/release_sources/prowlarr/__init__.py @@ -1,5 +1,4 @@ -""" -Prowlarr release source plugin. +"""Prowlarr release source plugin. This plugin integrates with Prowlarr to search for book releases across multiple indexers (torrent and usenet). @@ -9,17 +8,25 @@ Includes: - ProwlarrHandler: Download handling via external clients """ +from importlib import import_module + # Import submodules to trigger decorator registration -from shelfmark.release_sources.prowlarr import source # noqa: F401 -from shelfmark.release_sources.prowlarr import handler # noqa: F401 -from shelfmark.release_sources.prowlarr import settings # noqa: F401 +from shelfmark.release_sources.prowlarr import ( + handler as handler, +) +from shelfmark.release_sources.prowlarr import ( + settings as settings, +) +from shelfmark.release_sources.prowlarr import ( + source as source, +) # Import shared download clients/settings to trigger registration. # This is in a try/except to handle optional dependencies gracefully. try: - from shelfmark.download import clients # noqa: F401 - from shelfmark.download.clients import settings as client_settings # noqa: F401 + import_module("shelfmark.download.clients") + import_module("shelfmark.download.clients.settings") except ImportError as e: import logging - logging.getLogger(__name__).debug(f"Download clients not loaded: {e}") + logging.getLogger(__name__).debug("Download clients not loaded: %s", e) diff --git a/shelfmark/release_sources/prowlarr/api.py b/shelfmark/release_sources/prowlarr/api.py index d1bbb2ec..f1e7bc36 100644 --- a/shelfmark/release_sources/prowlarr/api.py +++ b/shelfmark/release_sources/prowlarr/api.py @@ -1,6 +1,7 @@ """Prowlarr API client for connection testing, indexer listing, and search.""" -from typing import Any, Dict, List, Optional, Tuple +from http import HTTPStatus +from typing import Any import requests @@ -11,30 +12,36 @@ from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml logger = setup_logger(__name__) +_HTTP_STATUS_UNAUTHORIZED = HTTPStatus.UNAUTHORIZED +_BOOK_CATEGORY_RANGE_START = 7000 +_BOOK_CATEGORY_RANGE_END = 8000 + class ProwlarrClient: """Client for interacting with the Prowlarr API.""" - def __init__(self, url: str, api_key: str, timeout: int = 30): + def __init__(self, url: str, api_key: str, timeout: int = 30) -> None: self.base_url = normalize_http_url(url) self.api_key = api_key self.timeout = timeout self._session = requests.Session() - self._session.headers.update({ - "X-Api-Key": api_key, - "Accept": "application/json", - }) + self._session.headers.update( + { + "X-Api-Key": api_key, + "Accept": "application/json", + } + ) def _request( self, method: str, endpoint: str, - params: Optional[Dict[str, Any]] = None, - json_data: Optional[Dict[str, Any]] = None, - ) -> Any: + params: dict[str, Any] | None = None, + json_data: dict[str, Any] | None = None, + ) -> object: """Make an API request to Prowlarr. Returns parsed JSON response.""" url = self.base_url + endpoint - logger.debug(f"Prowlarr API: {method} {url}") + logger.debug("Prowlarr API: %s %s", method, url) try: response = self._session.request( @@ -49,7 +56,7 @@ class ProwlarrClient: if not response.ok: try: error_body = response.text[:500] - logger.error(f"Prowlarr API error response: {error_body}") + logger.error("Prowlarr API error response: %s", error_body) except Exception: pass @@ -57,45 +64,49 @@ class ProwlarrClient: return response.json() except requests.exceptions.JSONDecodeError as e: - logger.error(f"Invalid JSON response from Prowlarr: {e}") - raise ValueError(f"Invalid JSON response: {e}") + logger.exception("Invalid JSON response from Prowlarr") + msg = f"Invalid JSON response: {e}" + raise ValueError(msg) from e except requests.exceptions.HTTPError as e: - logger.error(f"Prowlarr API HTTP error: {e.response.status_code} {e.response.reason}") + logger.exception( + "Prowlarr API HTTP error: %s %s", + e.response.status_code, + e.response.reason, + ) raise - except requests.exceptions.RequestException as e: - logger.error(f"Prowlarr API request failed: {e}") + except requests.exceptions.RequestException: + logger.exception("Prowlarr API request failed") raise - def test_connection(self) -> Tuple[bool, str]: + def test_connection(self) -> tuple[bool, str]: """Test connection to Prowlarr. Returns (success, message).""" - logger.info(f"Testing Prowlarr connection to: {self.base_url}") + logger.info("Testing Prowlarr connection to: %s", self.base_url) try: data = self._request("GET", "/api/v1/system/status") version = data.get("version", "unknown") - logger.info(f"Prowlarr connection successful: version {version}") - return True, f"Connected to Prowlarr {version}" except requests.exceptions.ConnectionError: return False, "Could not connect to Prowlarr. Check the URL." except requests.exceptions.HTTPError as e: status = e.response.status_code if e.response is not None else "unknown" - if e.response is not None and e.response.status_code == 401: + if e.response is not None and e.response.status_code == _HTTP_STATUS_UNAUTHORIZED: return False, "Invalid API key" return False, f"HTTP error {status}" except Exception as e: - return False, f"Connection failed: {str(e)}" + return False, f"Connection failed: {e!s}" + else: + logger.info("Prowlarr connection successful: version %s", version) + return True, f"Connected to Prowlarr {version}" - def get_indexers(self) -> List[Dict[str, Any]]: + def get_indexers(self) -> list[dict[str, Any]]: """Get all configured indexers.""" try: - indexers = self._request("GET", "/api/v1/indexer") - return indexers - except Exception as e: - logger.error(f"Failed to get indexers: {e}") + return self._request("GET", "/api/v1/indexer") + except Exception: + logger.exception("Failed to get indexers") return [] - def get_enabled_indexers_detailed(self) -> List[Dict[str, Any]]: - """ - Get enabled indexers, including implementation metadata. + def get_enabled_indexers_detailed(self) -> list[dict[str, Any]]: + """Get enabled indexers, including implementation metadata. Note: Prowlarr indexer "name" is user-configurable; prefer "implementation"/"implementationName" for stable identification. @@ -103,14 +114,14 @@ class ProwlarrClient: indexers = self.get_indexers() return [idx for idx in indexers if idx.get("enable", False)] - def get_enriched_indexer_ids(self, *, restrict_to: Optional[List[int]] = None) -> List[int]: - """ - Return enabled indexer IDs that benefit from extra Torznab handling. + def get_enriched_indexer_ids(self, *, restrict_to: list[int] | None = None) -> list[int]: + """Return enabled indexer IDs that benefit from extra Torznab handling. Args: restrict_to: Optional list of candidate indexer IDs to consider. + """ - enriched_ids: List[int] = [] + enriched_ids: list[int] = [] for idx in self.get_enabled_indexers_detailed(): idx_id = idx.get("id") @@ -118,20 +129,25 @@ class ProwlarrClient: continue try: idx_id_int = int(idx_id) - except (TypeError, ValueError): + except TypeError, ValueError: continue if restrict_to is not None and idx_id_int not in restrict_to: continue - impl = str(idx.get("implementation") or idx.get("implementationName") or idx.get("definitionName") or "") + impl = str( + idx.get("implementation") + or idx.get("implementationName") + or idx.get("definitionName") + or "" + ) # Currently only MyAnonamouse provides consistently rich Torznab metadata. if impl.strip().lower() == "myanonamouse": enriched_ids.append(idx_id_int) return enriched_ids - def get_enabled_indexers(self) -> List[Dict[str, Any]]: + def get_enabled_indexers(self) -> list[dict[str, Any]]: """Get enabled indexers with book capability info.""" indexers = self.get_indexers() result = [] @@ -144,12 +160,14 @@ class ProwlarrClient: categories = idx.get("capabilities", {}).get("categories", []) has_books = self._has_book_categories(categories) - result.append({ - "id": idx.get("id"), - "name": idx.get("name"), - "protocol": idx.get("protocol"), - "has_books": has_books, - }) + result.append( + { + "id": idx.get("id"), + "name": idx.get("name"), + "protocol": idx.get("protocol"), + "has_books": has_books, + } + ) return result @@ -158,13 +176,12 @@ class ProwlarrClient: *, indexer_id: int, query: str, - categories: Optional[List[int]] = None, + categories: list[int] | None = None, search_type: str = "book", limit: int = 100, offset: int = 0, - ) -> List[Dict[str, Any]]: - """ - Search a specific indexer via Prowlarr's Torznab/Newznab endpoint. + ) -> list[dict[str, Any]]: + """Search a specific indexer via Prowlarr's Torznab/Newznab endpoint. This returns richer fields (e.g., author/booktitle, torznab tags like FreeLeech) than the JSON /api/v1/search endpoint. @@ -175,7 +192,7 @@ class ProwlarrClient: endpoint = f"/api/v1/indexer/{int(indexer_id)}/newznab" url = self.base_url + endpoint - params: Dict[str, Any] = { + params: dict[str, Any] = { "t": search_type, "q": query, "limit": limit, @@ -184,7 +201,7 @@ class ProwlarrClient: if categories: params["cat"] = ",".join(str(c) for c in categories) - logger.debug(f"Prowlarr API: GET {url} (torznab)") + logger.debug("Prowlarr API: GET %s (torznab)", url) try: response = self._session.get( @@ -200,7 +217,7 @@ class ProwlarrClient: if not response.ok: try: error_body = response.text[:500] - logger.error(f"Prowlarr Torznab error response: {error_body}") + logger.error("Prowlarr Torznab error response: %s", error_body) except Exception: pass response.raise_for_status() @@ -210,18 +227,19 @@ class ProwlarrClient: for r in results: if r.get("indexerId") is None: r["indexerId"] = int(indexer_id) - return results - except Exception as e: - logger.error(f"Prowlarr Torznab search failed for indexer {indexer_id}: {e}") + except Exception: + logger.exception("Prowlarr Torznab search failed for indexer %s", indexer_id) return [] + else: + return results - def _has_book_categories(self, categories: List[Dict[str, Any]]) -> bool: + def _has_book_categories(self, categories: list[dict[str, Any]]) -> bool: """Check if any category or subcategory is in the book range (7000-7999).""" for cat in categories: cat_id = cat.get("id", 0) - if 7000 <= cat_id <= 7999: + if _BOOK_CATEGORY_RANGE_START <= cat_id < _BOOK_CATEGORY_RANGE_END: return True for subcat in cat.get("subCategories", []): - if 7000 <= subcat.get("id", 0) <= 7999: + if _BOOK_CATEGORY_RANGE_START <= subcat.get("id", 0) < _BOOK_CATEGORY_RANGE_END: return True return False diff --git a/shelfmark/release_sources/prowlarr/cache.py b/shelfmark/release_sources/prowlarr/cache.py index bb0a3065..780cb375 100644 --- a/shelfmark/release_sources/prowlarr/cache.py +++ b/shelfmark/release_sources/prowlarr/cache.py @@ -1,5 +1,4 @@ -""" -Prowlarr release cache. +"""Prowlarr release cache. Stores search results so the handler can look up releases by source_id. This keeps all Prowlarr-specific data within the plugin. @@ -7,7 +6,6 @@ This keeps all Prowlarr-specific data within the plugin. import time from threading import Lock -from typing import Dict, Optional from shelfmark.core.logger import setup_logger @@ -17,35 +15,35 @@ logger = setup_logger(__name__) RELEASE_CACHE_TTL = 3600 # Internal cache storage: source_id -> (release_dict, timestamp) -_cache: Dict[str, tuple] = {} +_cache: dict[str, tuple] = {} _cache_lock = Lock() def cache_release(source_id: str, release_data: dict) -> None: - """ - Cache a release by its source_id. + """Cache a release by its source_id. Args: source_id: The unique identifier for this release (GUID) release_data: The full Prowlarr API result dict + """ with _cache_lock: _cache[source_id] = (release_data, time.time()) -def get_release(source_id: str) -> Optional[dict]: - """ - Get a cached release by source_id. +def get_release(source_id: str) -> dict | None: + """Get a cached release by source_id. Args: source_id: The unique identifier for the release Returns: The cached release dict, or None if not found or expired + """ with _cache_lock: if source_id not in _cache: - logger.debug(f"Prowlarr release not in cache: {source_id}") + logger.debug("Prowlarr release not in cache: %s", source_id) return None release_data, cached_at = _cache[source_id] @@ -54,31 +52,31 @@ def get_release(source_id: str) -> Optional[dict]: if age > RELEASE_CACHE_TTL: # Expired - remove from cache del _cache[source_id] - logger.debug(f"Prowlarr release expired: {source_id}") + logger.debug("Prowlarr release expired: %s", source_id) return None return release_data def remove_release(source_id: str) -> None: - """ - Remove a release from the cache (e.g., after successful download). + """Remove a release from the cache (e.g., after successful download). Args: source_id: The unique identifier for the release + """ with _cache_lock: if source_id in _cache: del _cache[source_id] - logger.debug(f"Removed Prowlarr release from cache: {source_id}") + logger.debug("Removed Prowlarr release from cache: %s", source_id) def cleanup_expired() -> int: - """ - Remove all expired entries from the cache. + """Remove all expired entries from the cache. Returns: Number of entries removed + """ current_time = time.time() removed = 0 @@ -94,17 +92,17 @@ def cleanup_expired() -> int: removed += 1 if removed: - logger.debug(f"Cleaned up {removed} expired Prowlarr cache entries") + logger.debug("Cleaned up %s expired Prowlarr cache entries", removed) return removed def get_cache_stats() -> dict: - """ - Get cache statistics for debugging. + """Get cache statistics for debugging. Returns: Dict with cache stats + """ with _cache_lock: return { diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index e42468b1..f67c58d3 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -1,22 +1,39 @@ """Prowlarr download handler - resolves releases and delegates lifecycle to shared clients.""" -from typing import Callable, Optional +from typing import TYPE_CHECKING -from shelfmark.core.config import config # noqa: F401 (compat patch target in tests) +from shelfmark.core.config import config as config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import DownloadTask from shelfmark.core.request_helpers import normalize_optional_text -from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients +from shelfmark.download.clients import ( + DownloadClient, + get_client, + list_configured_clients, +) from shelfmark.download.clients.base_handler import ( COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS, +) +from shelfmark.download.clients.base_handler import ( COMPLETED_PATH_RETRY_INTERVAL as _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL, +) +from shelfmark.download.clients.base_handler import ( POLL_INTERVAL as _DEFAULT_POLL_INTERVAL, +) +from shelfmark.download.clients.base_handler import ( DownloadRequest, ExternalClientHandler, ) from shelfmark.release_sources import register_handler from shelfmark.release_sources.prowlarr.cache import get_release, remove_release -from shelfmark.release_sources.prowlarr.utils import get_preferred_download_url, get_protocol +from shelfmark.release_sources.prowlarr.utils import ( + get_preferred_download_url, + get_protocol, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from shelfmark.core.models import DownloadTask logger = setup_logger(__name__) @@ -26,19 +43,19 @@ COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS -def _coerce_seed_time_minutes(raw_seed_time: object) -> Optional[int]: +def _coerce_seed_time_minutes(raw_seed_time: object) -> int | None: """Convert Prowlarr's minimum seed time from seconds to whole minutes.""" if raw_seed_time is None: return None try: seed_time_seconds = int(raw_seed_time) - except (TypeError, ValueError): - logger.warning(f"Invalid Prowlarr minimumSeedTime value: {raw_seed_time!r}") + except TypeError, ValueError: + logger.warning("Invalid Prowlarr minimumSeedTime value: %r", raw_seed_time) return None if seed_time_seconds < 0: - logger.warning(f"Ignoring negative Prowlarr minimumSeedTime value: {seed_time_seconds}") + logger.warning("Ignoring negative Prowlarr minimumSeedTime value: %s", seed_time_seconds) return None # Round up so we never under-seed when a tracker uses a non-minute boundary. @@ -49,7 +66,7 @@ def _coerce_seed_time_minutes(raw_seed_time: object) -> Optional[int]: class ProwlarrHandler(ExternalClientHandler): """Handler for Prowlarr downloads via configured torrent or usenet client.""" - def _get_client(self, protocol: str) -> Optional[DownloadClient]: + def _get_client(self, protocol: str) -> DownloadClient | None: """Compatibility shim so module-level patching still works in tests.""" return get_client(protocol) @@ -67,7 +84,7 @@ class ProwlarrHandler(ExternalClientHandler): return COMPLETED_PATH_MAX_ATTEMPTS @classmethod - def _restore_download_request_from_task(cls, task: DownloadTask) -> Optional[DownloadRequest]: + def _restore_download_request_from_task(cls, task: DownloadTask) -> DownloadRequest | None: """Rebuild a DownloadRequest when the in-memory Prowlarr cache is gone.""" retry_download_url = normalize_optional_text(getattr(task, "retry_download_url", None)) retry_download_protocol = normalize_optional_text( @@ -104,15 +121,15 @@ class ProwlarrHandler(ExternalClientHandler): def _resolve_download( self, task: DownloadTask, - status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[DownloadRequest]: + status_callback: Callable[[str, str | None], None], + ) -> DownloadRequest | None: """Resolve Prowlarr cache entry into download request parameters.""" # Look up the cached release prowlarr_result = get_release(task.task_id) if not prowlarr_result: restored_request = self._restore_download_request_from_task(task) if restored_request is None: - logger.warning(f"Release cache miss: {task.task_id}") + logger.warning("Release cache miss: %s", task.task_id) status_callback("error", "Release not found in cache (may have expired)") return None logger.info("Restored Prowlarr download request for retry: %s", task.task_id) @@ -155,6 +172,6 @@ class ProwlarrHandler(ExternalClientHandler): def cancel(self, task_id: str) -> bool: """Cancel download and clean up cache. Primary cancellation is via cancel_flag.""" - logger.debug(f"Cancel requested for Prowlarr task: {task_id}") + logger.debug("Cancel requested for Prowlarr task: %s", task_id) remove_release(task_id) return super().cancel(task_id) diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index 9e2ae81c..ef2cdad4 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -1,25 +1,24 @@ """Prowlarr settings registration.""" -from typing import Any, Dict, List, Optional +from typing import Any from shelfmark.core.settings_registry import ( - register_settings, + ActionButton, CheckboxField, HeadingField, - TextField, - PasswordField, - ActionButton, MultiSelectField, + PasswordField, + SettingsField, + TextField, + register_settings, ) from shelfmark.core.utils import normalize_http_url - # ==================== Dynamic Options Loaders ==================== -def _get_indexer_options() -> List[Dict[str, str]]: - """ - Fetch available indexers from Prowlarr for the multi-select field. +def _get_indexer_options() -> list[dict[str, str]]: + """Fetch available indexers from Prowlarr for the multi-select field. Returns list of {value: "id", label: "name (protocol)"} options. """ @@ -56,22 +55,25 @@ def _get_indexer_options() -> List[Dict[str, str]]: if has_books: label += " 📚" - options.append({ - "value": str(idx_id), - "label": label, - }) + options.append( + { + "value": str(idx_id), + "label": label, + } + ) - return options - - except Exception as e: - logger.error(f"Failed to fetch Prowlarr indexers: {e}") + except Exception: + logger.exception("Failed to fetch Prowlarr indexers") return [] + else: + return options + # ==================== Test Connection Callback ==================== -def _test_prowlarr_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def _test_prowlarr_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: """Test the Prowlarr connection using current form values.""" from shelfmark.core.config import config from shelfmark.release_sources.prowlarr.api import ProwlarrClient @@ -93,9 +95,10 @@ def _test_prowlarr_connection(current_values: Optional[Dict[str, Any]] = None) - try: client = ProwlarrClient(url, api_key) success, message = client.test_connection() - return {"success": success, "message": message} except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} + return {"success": False, "message": f"Connection failed: {e!s}"} + else: + return {"success": success, "message": message} # ==================== Configuration Tab ==================== @@ -107,7 +110,7 @@ def _test_prowlarr_connection(current_values: Optional[Dict[str, Any]] = None) - icon="download", order=41, ) -def prowlarr_config_settings(): +def prowlarr_config_settings() -> list[SettingsField]: """Prowlarr connection and indexer settings.""" return [ HeadingField( diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index dd5801a0..cf5a50ce 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -2,39 +2,49 @@ import re import time -from typing import List, Optional, TYPE_CHECKING +from contextlib import suppress +from typing import TYPE_CHECKING, ClassVar, NoReturn if TYPE_CHECKING: from shelfmark.core.search_plan import ReleaseSearchPlan - -from shelfmark.core.search_plan import ReleaseSearchVariant + from shelfmark.metadata_providers import BookMetadata from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.metadata_providers import BookMetadata +from shelfmark.core.search_plan import ReleaseSearchVariant +from shelfmark.core.utils import normalize_http_url from shelfmark.release_sources import ( - Release, - ReleaseProtocol, - ReleaseSource, - register_source, - ReleaseColumnConfig, - ColumnSchema, - ColumnRenderType, ColumnAlign, ColumnColorHint, + ColumnRenderType, + ColumnSchema, LeadingCellConfig, LeadingCellType, + Release, + ReleaseColumnConfig, + ReleaseProtocol, + ReleaseSource, SortOption, + register_source, ) from shelfmark.release_sources.prowlarr.api import ProwlarrClient -from shelfmark.core.utils import normalize_http_url from shelfmark.release_sources.prowlarr.cache import cache_release -from shelfmark.release_sources.prowlarr.utils import get_preferred_download_url, get_protocol +from shelfmark.release_sources.prowlarr.utils import ( + get_preferred_download_url, + get_protocol, +) logger = setup_logger(__name__) +_SIZE_UNIT_BASE = 1024 +_TWO_FORMATS = 2 -def _parse_size(size_bytes: Optional[int]) -> Optional[str]: + +def _raise_timeout_error(message: str) -> NoReturn: + raise TimeoutError(message) + + +def _parse_size(size_bytes: int | None) -> str | None: """Convert bytes to human-readable size string.""" if size_bytes is None or size_bytes <= 0: return None @@ -43,8 +53,8 @@ def _parse_size(size_bytes: Optional[int]) -> Optional[str]: size = float(size_bytes) unit_index = 0 - while size >= 1024 and unit_index < len(units) - 1: - size /= 1024 + while size >= _SIZE_UNIT_BASE and unit_index < len(units) - 1: + size /= _SIZE_UNIT_BASE unit_index += 1 if unit_index == 0: @@ -54,7 +64,20 @@ def _parse_size(size_bytes: Optional[int]) -> Optional[str]: # Common ebook formats in priority order -EBOOK_FORMATS = ["epub", "mobi", "azw3", "azw", "pdf", "cbz", "cbr", "fb2", "djvu", "lit", "pdb", "txt"] +EBOOK_FORMATS = [ + "epub", + "mobi", + "azw3", + "azw", + "pdf", + "cbz", + "cbr", + "fb2", + "djvu", + "lit", + "pdb", + "txt", +] # Common audiobook formats AUDIOBOOK_FORMATS = ["m4b", "mp3", "m4a", "flac", "ogg", "wma", "aac", "wav", "opus"] @@ -111,16 +134,16 @@ MAM_LANGUAGE_MAP = { PROWLARR_SEARCH_TIMEOUT_SECONDS = 120.0 -def _extract_format(title: str) -> Optional[str]: +def _extract_format(title: str) -> str | None: """Extract ebook/audiobook format from release title (extension, bracketed, or standalone).""" title_lower = title.lower() # Pattern priority: file extension > bracketed > standalone word # Use %s placeholder since {fmt} conflicts with regex syntax pattern_templates = [ - r'\.%s(?:["\'\s\]\)]|$)', # .format at end or followed by delimiter - r'[\[\(\{]%s[\]\)\}]', # [EPUB], (PDF), {mobi} - r'\b%s\b', # standalone word + r'\.%s(?:["\'\s\]\)]|$)', # .format at end or followed by delimiter + r"[\[\(\{]%s[\]\)\}]", # [EPUB], (PDF), {mobi} + r"\b%s\b", # standalone word ] for template in pattern_templates: @@ -131,9 +154,8 @@ def _extract_format(title: str) -> Optional[str]: return None -def _extract_mam_language(raw_title: str) -> Optional[str]: - """ - Extract the language code from MyAnonamouse titles. +def _extract_mam_language(raw_title: str) -> str | None: + """Extract the language code from MyAnonamouse titles. Prowlarr's MAM parser appends a structured bracket segment like: [ENG / EPUB MOBI PDF] @@ -160,9 +182,8 @@ def _extract_mam_language(raw_title: str) -> Optional[str]: return None -def _extract_mam_formats(raw_title: str) -> List[str]: - """ - Extract a list of formats from MyAnonamouse titles. +def _extract_mam_formats(raw_title: str) -> list[str]: + """Extract a list of formats from MyAnonamouse titles. Prowlarr's MAM parser appends a structured bracket segment like: [ENG / EPUB MOBI PDF] @@ -181,7 +202,7 @@ def _extract_mam_formats(raw_title: str) -> List[str]: _, after_slash = bracket.split("/", 1) tokens = re.findall(r"[A-Za-z0-9]+", after_slash) - formats: List[str] = [] + formats: list[str] = [] for token in tokens: fmt = token.lower() if fmt in format_set and fmt not in formats: @@ -193,12 +214,12 @@ def _extract_mam_formats(raw_title: str) -> List[str]: return [] -def _formats_display(formats: List[str]) -> Optional[str]: +def _formats_display(formats: list[str]) -> str | None: if not formats: return None if len(formats) == 1: return formats[0] - if len(formats) == 2: + if len(formats) == _TWO_FORMATS: return f"{formats[0]}, {formats[1]}" # Show first two formats + count of others to prevent overflow return f"{formats[0]}, {formats[1]} +{len(formats) - 2}" @@ -260,7 +281,7 @@ def _extract_capability_category_ids(categories: list[dict]) -> set[int]: return category_ids -def _indexer_supports_search_categories(indexer: dict, categories: Optional[List[int]]) -> bool: +def _indexer_supports_search_categories(indexer: dict, categories: list[int] | None) -> bool: """Return whether an indexer should be queried for the requested categories.""" if not categories: return True @@ -271,7 +292,7 @@ def _indexer_supports_search_categories(indexer: dict, categories: Optional[List return True for requested_category in categories: - if requested_category == 7000: + if requested_category in BOOK_CATEGORY_RANGE: if any(cat_id in BOOK_CATEGORY_RANGE for cat_id in category_ids): return True continue @@ -299,7 +320,7 @@ def _prowlarr_result_to_release( categories = result.get("categories", []) is_torrent = protocol == ReleaseProtocol.TORRENT raw_indexer_flags = result.get("indexerFlags") or [] - indexer_flags: List[str] = [] + indexer_flags: list[str] = [] seen_flags: set[str] = set() def add_indexer_flag(flag: object) -> None: @@ -327,10 +348,10 @@ def _prowlarr_result_to_release( else None ) - format_detected: Optional[str] = None - formats: List[str] = [] - formats_display: Optional[str] = None - language_detected: Optional[str] = None + format_detected: str | None = None + formats: list[str] = [] + formats_display: str | None = None + language_detected: str | None = None if enable_format_detection: book_title = str(result.get("bookTitle") or "").strip() if book_title: @@ -353,7 +374,7 @@ def _prowlarr_result_to_release( try: if download_volume_factor is not None and float(download_volume_factor) == 0.0: is_freeleech = True - except (TypeError, ValueError): + except TypeError, ValueError: pass if any(flag.lower() in {"freeleech", "fl"} for flag in indexer_flags): @@ -402,7 +423,7 @@ def _prowlarr_result_to_release( "minimum_ratio": result.get("minimumRatio"), "minimum_seed_time": result.get("minimumSeedTime"), "info_hash": result.get("infoHash"), - "formats": formats if formats else None, + "formats": formats or None, "formats_display": formats_display, # Raw torznab attributes for rich tooltips (enriched indexers) "torznab_attrs": result.get("torznabAttrs"), @@ -416,16 +437,19 @@ class ProwlarrSource(ReleaseSource): name = "prowlarr" display_name = "Prowlarr" - supported_content_types = ["ebook", "audiobook"] # Explicitly declare support for both + supported_content_types: ClassVar[list[str]] = [ + "ebook", + "audiobook", + ] # Explicitly declare support for both - def __init__(self): - self.last_search_type: Optional[str] = None + def __init__(self) -> None: + self.last_search_type: str | None = None def get_column_config(self) -> ReleaseColumnConfig: """Column configuration for Prowlarr releases.""" # Fetch available indexers from Prowlarr - available_indexers: Optional[List[str]] = None - default_indexers: Optional[List[str]] = None + available_indexers: list[str] | None = None + default_indexers: list[str] | None = None client = self._get_client() if client: try: @@ -450,14 +474,16 @@ class ProwlarrSource(ReleaseSource): try: if int(idx_id) in selected_ids: selected_indexer_names.append(idx_name) - except (TypeError, ValueError): + except TypeError, ValueError: pass available_indexers = sorted(all_indexer_names) if all_indexer_names else None # Only set default_indexers if user has selected specific ones - default_indexers = sorted(selected_indexer_names) if selected_indexer_names else None + default_indexers = ( + sorted(selected_indexer_names) if selected_indexer_names else None + ) except Exception as e: - logger.warning(f"Failed to fetch indexer list for column config: {e}") + logger.warning("Failed to fetch indexer list for column config: %s", e) return ReleaseColumnConfig( columns=[ @@ -518,13 +544,18 @@ class ProwlarrSource(ReleaseSource): SortOption(label="Peers", sort_key="seeders"), ], grid_template="minmax(0,2fr) minmax(140px,1fr) 50px 50px 90px 80px", - leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), # No leading cell for Prowlarr + leading_cell=LeadingCellConfig( + type=LeadingCellType.NONE + ), # No leading cell for Prowlarr available_indexers=available_indexers, default_indexers=default_indexers, - supported_filters=["language", "indexer"], # Enables multi-language query expansion and indexer filtering + supported_filters=[ + "language", + "indexer", + ], # Enables multi-language query expansion and indexer filtering ) - def _get_client(self) -> Optional[ProwlarrClient]: + def _get_client(self) -> ProwlarrClient | None: """Get a configured Prowlarr client or None if not configured.""" raw_url = config.get("PROWLARR_URL", "") api_key = config.get("PROWLARR_API_KEY", "") @@ -538,9 +569,8 @@ class ProwlarrSource(ReleaseSource): return ProwlarrClient(url, api_key) - def _get_selected_indexer_ids(self) -> Optional[List[int]]: - """ - Get list of selected indexer IDs from config. + def _get_selected_indexer_ids(self) -> list[int] | None: + """Get list of selected indexer IDs from config. Returns None if no indexers are selected (search all). Returns list of IDs if specific indexers are selected. @@ -557,16 +587,16 @@ class ProwlarrSource(ReleaseSource): else: # Comma-separated string from env var ids = [int(x.strip()) for x in selected.split(",") if x.strip()] - return ids if ids else None except (ValueError, TypeError) as e: - logger.warning(f"Invalid PROWLARR_INDEXERS format: {selected} ({e})") + logger.warning("Invalid PROWLARR_INDEXERS format: %s (%s)", selected, e) return None + else: + return ids or None def _resolve_indexer_ids_from_names( - self, client: ProwlarrClient, names: List[str] - ) -> Optional[List[int]]: - """ - Convert indexer names to IDs by looking up enabled indexers. + self, client: ProwlarrClient, names: list[str] + ) -> list[int] | None: + """Convert indexer names to IDs by looking up enabled indexers. Returns None if no names could be resolved. """ @@ -585,22 +615,20 @@ class ProwlarrSource(ReleaseSource): for name in names: idx_id = name_to_id.get(name) if idx_id is not None: - try: + with suppress(TypeError, ValueError): ids.append(int(idx_id)) - except (TypeError, ValueError): - pass - - return ids if ids else None except Exception as e: - logger.warning(f"Failed to resolve indexer names to IDs: {e}") + logger.warning("Failed to resolve indexer names to IDs: %s", e) return None + else: + return ids or None def _get_search_indexer_ids( self, client: ProwlarrClient, - selected_indexer_ids: Optional[List[int]], - categories: Optional[List[int]], - ) -> List[int]: + selected_indexer_ids: list[int] | None, + categories: list[int] | None, + ) -> list[int]: """Resolve the concrete indexer IDs to query via Torznab.""" if selected_indexer_ids is not None: return selected_indexer_ids @@ -608,10 +636,10 @@ class ProwlarrSource(ReleaseSource): try: enabled_indexers = client.get_enabled_indexers_detailed() except Exception as e: - logger.warning(f"Failed to load enabled Prowlarr indexers: {e}") + logger.warning("Failed to load enabled Prowlarr indexers: %s", e) return [] - indexer_ids: List[int] = [] + indexer_ids: list[int] = [] for indexer in enabled_indexers: if not _indexer_supports_search_categories(indexer, categories): continue @@ -619,7 +647,7 @@ class ProwlarrSource(ReleaseSource): indexer_id = indexer.get("id") try: indexer_ids.append(int(indexer_id)) - except (TypeError, ValueError): + except TypeError, ValueError: continue return indexer_ids @@ -627,10 +655,11 @@ class ProwlarrSource(ReleaseSource): def search( self, book: BookMetadata, - plan: "ReleaseSearchPlan", # noqa: F821 + plan: ReleaseSearchPlan, + *, expand_search: bool = False, - content_type: str = "ebook" - ) -> List[Release]: + content_type: str = "ebook", + ) -> list[Release]: """Search Prowlarr indexers for releases matching the book.""" client = self._get_client() if not client: @@ -652,7 +681,11 @@ class ProwlarrSource(ReleaseSource): # Get indexer IDs: prefer plan.indexers (from filter), else use settings if plan.indexers: indexer_ids = self._resolve_indexer_ids_from_names(client, plan.indexers) - logger.debug(f"Using filter-specified indexers: {plan.indexers} -> IDs {indexer_ids}") + logger.debug( + "Using filter-specified indexers: %s -> IDs %s", + plan.indexers, + indexer_ids, + ) else: indexer_ids = self._get_selected_indexer_ids() @@ -680,11 +713,19 @@ class ProwlarrSource(ReleaseSource): indexer_desc = f"indexers={indexer_ids}" if indexer_ids else "all enabled indexers" if len(variants) == 1: logger.debug( - f"Searching Prowlarr: {query_type}='{variants[0].title}', {indexer_desc}, categories={categories}" + "Searching Prowlarr: %s='%s', %s, categories=%s", + query_type, + variants[0].title, + indexer_desc, + categories, ) else: logger.debug( - f"Searching Prowlarr: {query_type} ({len(variants)} variants), {indexer_desc}, categories={categories}" + "Searching Prowlarr: %s (%s variants), %s, categories=%s", + query_type, + len(variants), + indexer_desc, + categories, ) try: @@ -696,20 +737,26 @@ class ProwlarrSource(ReleaseSource): def _check_timeout() -> None: if time.monotonic() > deadline: - raise TimeoutError( + _raise_timeout_error( f"Prowlarr search timed out after {int(PROWLARR_SEARCH_TIMEOUT_SECONDS)}s" ) - def search_indexers(query: str, cats: Optional[List[int]], *, enriched_query: Optional[str] = None) -> List[dict]: + def search_indexers( + query: str, cats: list[int] | None, *, enriched_query: str | None = None + ) -> list[dict]: """Search indexers with given categories via Torznab/Newznab.""" - results: List[dict] = [] + results: list[dict] = [] target_indexer_ids = self._get_search_indexer_ids(client, indexer_ids, cats) if not target_indexer_ids: return results for indexer_id in target_indexer_ids: _check_timeout() - indexer_query = enriched_query if indexer_id in enriched_indexer_ids_set and enriched_query else query + indexer_query = ( + enriched_query + if indexer_id in enriched_indexer_ids_set and enriched_query + else query + ) raw = client.torznab_search( indexer_id=indexer_id, query=indexer_query, @@ -720,8 +767,9 @@ class ProwlarrSource(ReleaseSource): results.extend(raw) return results + seen_keys: set[str] = set() - all_results: List[dict] = [] + all_results: list[dict] = [] for idx, variant in enumerate(variants, start=1): _check_timeout() @@ -729,15 +777,22 @@ class ProwlarrSource(ReleaseSource): enriched_query = variant.query # title + author if len(variants) > 1: - logger.debug(f"Prowlarr query {idx}/{len(variants)}: '{query}'") + logger.debug("Prowlarr query %s/%s: '%s'", idx, len(variants), query) - raw_results = search_indexers(query=query, cats=categories, enriched_query=enriched_query) + raw_results = search_indexers( + query=query, cats=categories, enriched_query=enriched_query + ) # Auto-expand: if no results with categories and auto-expand enabled, retry without if not raw_results and categories and auto_expand_enabled: _check_timeout() - logger.info(f"Prowlarr: no results for query '{query}' with category filter, auto-expanding search") - raw_results = search_indexers(query=query, cats=None, enriched_query=enriched_query) + logger.info( + "Prowlarr: no results for query '%s' with category filter, auto-expanding search", + query, + ) + raw_results = search_indexers( + query=query, cats=None, enriched_query=enriched_query + ) self.last_search_type = "expanded" for r in raw_results: @@ -753,17 +808,19 @@ class ProwlarrSource(ReleaseSource): seen_keys.add(key) all_results.append(r) - results: List[Release] = [] + results: list[Release] = [] enriched_source_ids: set[str] = set() for r in all_results: idx_id = r.get("indexerId") try: idx_id_int = int(idx_id) if idx_id is not None else None - except (TypeError, ValueError): + except TypeError, ValueError: idx_id_int = None - is_enriched = bool(idx_id_int is not None and idx_id_int in enriched_indexer_ids_set) + is_enriched = bool( + idx_id_int is not None and idx_id_int in enriched_indexer_ids_set + ) release = _prowlarr_result_to_release( r, content_type, @@ -775,25 +832,31 @@ class ProwlarrSource(ReleaseSource): enriched_source_ids.add(release.source_id) # Sort results: enriched indexers first, then others - results.sort(key=lambda r: (0 if r.source_id in enriched_source_ids else 1)) + results.sort(key=lambda r: 0 if r.source_id in enriched_source_ids else 1) if results: torrent_count = sum(1 for r in results if r.protocol == ReleaseProtocol.TORRENT) nzb_count = sum(1 for r in results if r.protocol == ReleaseProtocol.NZB) - indexers = sorted(set(r.indexer for r in results if r.indexer)) + indexers = sorted({r.indexer for r in results if r.indexer}) indexer_str = ", ".join(indexers) if indexers else "unknown" - logger.info(f"Prowlarr: {len(results)} results ({torrent_count} torrent, {nzb_count} nzb) from {indexer_str}") + logger.info( + "Prowlarr: %s results (%s torrent, %s nzb) from %s", + len(results), + torrent_count, + nzb_count, + indexer_str, + ) else: logger.debug("Prowlarr: no results found") - return results - except TimeoutError as e: - logger.warning(f"Prowlarr search timed out: {e}") + logger.warning("Prowlarr search timed out: %s", e) raise - except Exception as e: - logger.error(f"Prowlarr search failed: {e}") + except Exception: + logger.exception("Prowlarr search failed") return [] + else: + return results def is_available(self) -> bool: """Check if Prowlarr is enabled and configured.""" diff --git a/shelfmark/release_sources/prowlarr/torznab.py b/shelfmark/release_sources/prowlarr/torznab.py index d2651642..f96a370f 100644 --- a/shelfmark/release_sources/prowlarr/torznab.py +++ b/shelfmark/release_sources/prowlarr/torznab.py @@ -1,5 +1,4 @@ -""" -Torznab/Newznab (RSS/XML) helpers for Prowlarr. +"""Torznab/Newznab (RSS/XML) helpers for Prowlarr. Used to fetch richer metadata from specific indexers (e.g., MyAnonamouse) that isn't available via Prowlarr's JSON search endpoint. @@ -7,7 +6,7 @@ isn't available via Prowlarr's JSON search endpoint. from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any from defusedxml import ElementTree as DefusedElementTree from defusedxml.common import DefusedXmlException @@ -20,7 +19,7 @@ def _local_name(tag: str) -> str: return tag -def _coerce_int(value: Optional[str]) -> Optional[int]: +def _coerce_int(value: str | None) -> int | None: if value is None: return None value = value.strip() @@ -32,7 +31,7 @@ def _coerce_int(value: Optional[str]) -> Optional[int]: return None -def _coerce_float(value: Optional[str]) -> Optional[float]: +def _coerce_float(value: str | None) -> float | None: if value is None: return None value = value.strip() @@ -44,9 +43,8 @@ def _coerce_float(value: Optional[str]) -> Optional[float]: return None -def _strip_author_from_title(title: str, author: Optional[str]) -> str: - """ - Prowlarr's MyAnonamouse parser appends " by {author}" into the title while +def _strip_author_from_title(title: str, author: str | None) -> str: + """Prowlarr's MyAnonamouse parser appends " by {author}" into the title while also emitting author/booktitle fields. Shelfmark's UI shows author separately, so strip the duplicated " by author" segment when present. """ @@ -60,9 +58,8 @@ def _strip_author_from_title(title: str, author: Optional[str]) -> str: return title -def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]: - """ - Parse a Torznab/Newznab XML response into a list of dicts that roughly match +def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]: + """Parse a Torznab/Newznab XML response into a list of dicts that roughly match Prowlarr's JSON search results shape. """ if not xml_text or not xml_text.strip(): @@ -70,11 +67,11 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]: try: root = DefusedElementTree.fromstring(xml_text) - except (DefusedElementTree.ParseError, DefusedXmlException): + except DefusedElementTree.ParseError, DefusedXmlException: return [] items = root.findall(".//item") - results: List[Dict[str, Any]] = [] + results: list[dict[str, Any]] = [] for item in items: title = (item.findtext("title") or "").strip() @@ -89,7 +86,7 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]: enclosure_type = enclosure.get("type") if enclosure is not None else None enclosure_url = enclosure.get("url") if enclosure is not None else None - protocol: Optional[str] = None + protocol: str | None = None if enclosure_type == "application/x-bittorrent": protocol = "torrent" elif enclosure_type == "application/x-nzb": @@ -99,18 +96,22 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]: download_url = enclosure_url.strip() or None prowlarr_indexer_el = item.find("prowlarrindexer") - indexer_id = _coerce_int(prowlarr_indexer_el.get("id")) if prowlarr_indexer_el is not None else None - indexer_name = (prowlarr_indexer_el.text or "").strip() if prowlarr_indexer_el is not None else "" + indexer_id = ( + _coerce_int(prowlarr_indexer_el.get("id")) if prowlarr_indexer_el is not None else None + ) + indexer_name = ( + (prowlarr_indexer_el.text or "").strip() if prowlarr_indexer_el is not None else "" + ) - categories: List[int] = [] + categories: list[int] = [] for cat_el in item.findall("category"): cat_id = _coerce_int(cat_el.text) if cat_id is not None: categories.append(cat_id) # Collect torznab/newznab attr elements (namespaced). - attrs: Dict[str, str] = {} - tags: List[str] = [] + attrs: dict[str, str] = {} + tags: list[str] = [] for el in item.iter(): if _local_name(el.tag) != "attr": continue @@ -126,7 +127,7 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]: seeders = _coerce_int(attrs.get("seeders")) peers = _coerce_int(attrs.get("peers")) - leechers: Optional[int] = None + leechers: int | None = None if peers is not None and seeders is not None and peers >= seeders: leechers = peers - seeders @@ -141,32 +142,34 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]: cleaned_title = _strip_author_from_title(title, author) - results.append({ - "title": cleaned_title or title, - "guid": guid or info_url or download_url or f"{indexer_id}:{title}", - "size": size, - "protocol": protocol or "unknown", - "downloadUrl": download_url, - "infoUrl": info_url, - "publishDate": pub_date, - "indexer": indexer_name or None, - "indexerId": indexer_id, - "categories": categories, - "seeders": seeders, - "leechers": leechers, - "files": _coerce_int(attrs.get("files")), - "grabs": _coerce_int(attrs.get("grabs")), - "infoHash": info_hash, - "indexerFlags": tags, - # Optional richer fields (not available via JSON search) - "author": author, - "bookTitle": book_title, - "downloadVolumeFactor": download_volume_factor, - "uploadVolumeFactor": upload_volume_factor, - "minimumRatio": minimum_ratio, - "minimumSeedTime": minimum_seed_time, - # Pass through all torznab attributes for tooltip display - "torznabAttrs": attrs, - }) + results.append( + { + "title": cleaned_title or title, + "guid": guid or info_url or download_url or f"{indexer_id}:{title}", + "size": size, + "protocol": protocol or "unknown", + "downloadUrl": download_url, + "infoUrl": info_url, + "publishDate": pub_date, + "indexer": indexer_name or None, + "indexerId": indexer_id, + "categories": categories, + "seeders": seeders, + "leechers": leechers, + "files": _coerce_int(attrs.get("files")), + "grabs": _coerce_int(attrs.get("grabs")), + "infoHash": info_hash, + "indexerFlags": tags, + # Optional richer fields (not available via JSON search) + "author": author, + "bookTitle": book_title, + "downloadVolumeFactor": download_volume_factor, + "uploadVolumeFactor": upload_volume_factor, + "minimumRatio": minimum_ratio, + "minimumSeedTime": minimum_seed_time, + # Pass through all torznab attributes for tooltip display + "torznabAttrs": attrs, + } + ) return results diff --git a/shelfmark/release_sources/prowlarr/utils.py b/shelfmark/release_sources/prowlarr/utils.py index 2eb4f32e..9cb04f73 100644 --- a/shelfmark/release_sources/prowlarr/utils.py +++ b/shelfmark/release_sources/prowlarr/utils.py @@ -1,13 +1,14 @@ -""" -Shared utilities for Prowlarr release source. +"""Shared utilities for Prowlarr release source. Provides common helper functions used across the Prowlarr plugin. """ -from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse +if TYPE_CHECKING: + from pathlib import Path + def get_protocol(result: dict) -> str: """Get the download protocol from a Prowlarr result. @@ -65,7 +66,7 @@ def sanitize_download_url(download_url: str) -> str: return normalized lower = normalized.lower() - if not (lower.startswith("http://") or lower.startswith("https://")): + if not lower.startswith(("http://", "https://")): return normalized if " " not in normalized: @@ -92,14 +93,14 @@ def sanitize_download_url(download_url: str) -> str: def get_protocol_display(result: dict) -> str: - """ - Get a user-friendly display label for the protocol. + """Get a user-friendly display label for the protocol. Args: result: Prowlarr search result dictionary Returns: Display label: "torrent", "nzb", or "unknown" + """ protocol = get_protocol(result) if protocol == "usenet": @@ -108,8 +109,7 @@ def get_protocol_display(result: dict) -> str: def get_unique_path(staging_dir: Path, name: str, suffix: str = "") -> Path: - """ - Generate a unique path in staging_dir, appending _N if needed. + """Generate a unique path in staging_dir, appending _N if needed. Args: staging_dir: Directory to create the path in @@ -118,6 +118,7 @@ def get_unique_path(staging_dir: Path, name: str, suffix: str = "") -> Path: Returns: Unique Path that doesn't exist in staging_dir + """ staged_path = staging_dir / (name + suffix) if not staged_path.exists(): diff --git a/tests/README.md b/tests/README.md index 31ab34ab..212768f1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,14 +5,17 @@ This directory contains the test suite for Shelfmark. Tests are organized by sco ## Quick Start ```bash -# Run all unit tests (fast, no external dependencies) -docker exec test-cwabd python3 -m pytest tests/ -v -m "not integration and not e2e" +# Sync the local Python environment once +uv sync --locked --extra browser -# Run E2E API tests -docker exec test-cwabd python3 -m pytest tests/e2e/ -v -m e2e +# Run all unit tests locally (fast, no external dependencies) +uv run pytest tests/ -v -m "not integration and not e2e" -# Run everything except integration tests -docker exec test-cwabd python3 -m pytest tests/ -v -m "not integration" +# Run E2E API tests against a running app stack +uv run pytest tests/e2e/ -v -m e2e + +# Run everything except integration tests locally +uv run pytest tests/ -v -m "not integration" ``` ## Test Structure @@ -78,7 +81,7 @@ tests/ Fast tests that mock external dependencies. Run these frequently during development. ```bash -docker exec test-cwabd python3 -m pytest tests/prowlarr/ -v -m "not integration" +uv run pytest tests/prowlarr/ -v -m "not integration" ``` **What they test:** @@ -98,7 +101,7 @@ docker exec test-cwabd python3 -m pytest tests/prowlarr/ -v -m "not integration" Test the full application through its HTTP API. Require the app to be running. ```bash -docker exec test-cwabd python3 -m pytest tests/e2e/ -v -m e2e +uv run pytest tests/e2e/ -v -m e2e ``` **What they test:** @@ -118,7 +121,7 @@ Test against real services (qBittorrent, Transmission, etc.). Require the full D docker compose -f docker-compose.test-clients.yml up -d # Run integration tests -docker exec test-cwabd python3 -m pytest tests/prowlarr/ -v -m integration +docker compose -f docker-compose.test-clients.yml exec shelfmark uv run pytest tests/prowlarr/ -v -m integration ``` **What they test:** @@ -138,22 +141,22 @@ docker exec test-cwabd python3 -m pytest tests/prowlarr/ -v -m integration ```bash # Run specific test file -docker exec test-cwabd python3 -m pytest tests/prowlarr/test_clients.py -v +uv run pytest tests/prowlarr/test_clients.py -v # Run specific test class -docker exec test-cwabd python3 -m pytest tests/e2e/test_api.py::TestHealthEndpoint -v +uv run pytest tests/e2e/test_api.py::TestHealthEndpoint -v # Run specific test -docker exec test-cwabd python3 -m pytest tests/e2e/test_api.py::TestHealthEndpoint::test_health_returns_ok -v +uv run pytest tests/e2e/test_api.py::TestHealthEndpoint::test_health_returns_ok -v # Run with short traceback (cleaner output) -docker exec test-cwabd python3 -m pytest tests/ -v --tb=short -m "not integration" +uv run pytest tests/ -v --tb=short -m "not integration" # Run and stop on first failure -docker exec test-cwabd python3 -m pytest tests/ -v -x -m "not integration" +uv run pytest tests/ -v -x -m "not integration" # Run with coverage (if pytest-cov installed) -docker exec test-cwabd python3 -m pytest tests/ --cov=shelfmark -m "not integration" +uv run pytest tests/ --cov=shelfmark -m "not integration" ``` ## Writing New Tests @@ -184,11 +187,15 @@ from .conftest import APIClient, DownloadTracker @pytest.mark.e2e class TestMyEndpoint: - def test_endpoint_works(self, api_client: APIClient): - resp = api_client.get("/api/my-endpoint") + def test_endpoint_works(self, protected_api_client: APIClient): + resp = protected_api_client.get("/api/my-endpoint") assert resp.status_code == 200 - def test_with_cleanup(self, api_client: APIClient, download_tracker: DownloadTracker): + def test_with_cleanup( + self, + protected_api_client: APIClient, + download_tracker: DownloadTracker, + ): # Track IDs for automatic cleanup after test download_tracker.track("some-id") # ... test code ... @@ -200,7 +207,8 @@ class TestMyEndpoint: | Fixture | Scope | Description | |---------|-------|-------------| -| `api_client` | session | HTTP client for API calls | +| `api_client` | function | Fresh HTTP client for general E2E calls | +| `protected_api_client` | function | Authenticated client for protected-route E2Es | | `download_tracker` | function | Tracks downloads for cleanup | | `server_config` | session | Cached server configuration | @@ -228,20 +236,20 @@ Some tests skip when external services aren't available. This is normal: ### Tests can't connect to app ```bash -# Check the app is running -docker ps | grep test-cwabd +# Check the app/container is running +docker ps # Check app logs -docker logs test-cwabd +docker logs ``` ### Import errors ```bash -# Make sure you're running inside the container -docker exec test-cwabd python3 -m pytest ... +# Sync the local Python environment first +uv sync --locked --extra browser -# Not from your local machine -pytest ... # This won't work +# Then run tests from the repo root +uv run pytest ... ``` ### Integration tests failing @@ -257,5 +265,5 @@ docker ps | grep -E "qbittorrent|transmission|deluge|nzbget|sabnzbd" Restart the container to reset the in-memory queue between test runs: ```bash -docker restart test-cwabd +docker restart ``` diff --git a/tests/config/test_docker_volumes.py b/tests/config/test_docker_volumes.py index 9ead58a9..155b7a71 100644 --- a/tests/config/test_docker_volumes.py +++ b/tests/config/test_docker_volumes.py @@ -5,7 +5,7 @@ These tests verify the application handles various Docker volume configurations correctly, including named volumes, bind mounts, permission issues, and edge cases that commonly cause issues in containerized deployments. -Run with: docker exec test-cwabd python3 -m pytest /app/tests/config/test_docker_volumes.py -v +Run with: uv run pytest tests/config/test_docker_volumes.py -v """ import json @@ -263,6 +263,16 @@ class TestPermissions: class TestPathEdgeCases: """Tests for edge cases in path handling.""" + @pytest.mark.parametrize("tab_name", ["../escape", "nested/plugin", "..", "."]) + def test_plugin_tab_name_path_traversal_rejected(self, tab_name): + """Plugin tab names must stay inside the plugins config directory.""" + from shelfmark.core.settings_registry import _get_config_file_path + + with tempfile.TemporaryDirectory() as tmpdir: + with patch("shelfmark.config.env.CONFIG_DIR", Path(tmpdir)): + with pytest.raises(ValueError, match="Invalid tab name"): + _get_config_file_path(tab_name) + def test_config_dir_with_spaces(self): """Config directory with spaces in path should work.""" from shelfmark.core.settings_registry import ( diff --git a/tests/config/test_environment.py b/tests/config/test_environment.py index 7d78fc8c..3ba0234b 100644 --- a/tests/config/test_environment.py +++ b/tests/config/test_environment.py @@ -4,7 +4,7 @@ Environment and configuration tests. These tests verify the application behaves correctly with different configuration settings, environment variables, and Docker setups. -Run with: docker exec test-cwabd python3 -m pytest /app/tests/config/test_environment.py -v +Run with: uv run pytest tests/config/test_environment.py -v """ import json diff --git a/tests/core/test_config_api.py b/tests/core/test_config_api.py index 92ee72a5..4d903f03 100644 --- a/tests/core/test_config_api.py +++ b/tests/core/test_config_api.py @@ -4,6 +4,7 @@ from __future__ import annotations import importlib import uuid +from pathlib import Path from unittest.mock import patch import pytest @@ -48,9 +49,21 @@ def test_config_includes_release_source_links_toggle(main_module, client): with patch.object(main_module, "get_auth_mode", return_value="builtin"): with patch.object(main_module.app_config, "get", side_effect=fake_get): with patch("shelfmark.metadata_providers.get_provider_sort_options", return_value=[]): - with patch("shelfmark.metadata_providers.get_provider_search_fields", return_value=[]): - with patch("shelfmark.metadata_providers.get_provider_default_sort", return_value="relevance"): + with patch( + "shelfmark.metadata_providers.get_provider_search_fields", return_value=[] + ): + with patch( + "shelfmark.metadata_providers.get_provider_default_sort", + return_value="relevance", + ): resp = client.get("/api/config") assert resp.status_code == 200 assert resp.json["show_release_source_links"] is False + + +def test_frontend_dist_resolves_from_repo_root(main_module): + expected_project_root = Path(main_module.__file__).resolve().parent.parent + + assert main_module.PROJECT_ROOT == expected_project_root + assert main_module.FRONTEND_DIST == expected_project_root / "frontend-dist" diff --git a/tests/core/test_notifications.py b/tests/core/test_notifications.py index 31f626c6..50d084c7 100644 --- a/tests/core/test_notifications.py +++ b/tests/core/test_notifications.py @@ -260,9 +260,7 @@ def test_dispatch_to_apprise_passes_shelfmark_asset_to_instantiate(monkeypatch): def test_dispatch_to_apprise_logs_captured_apprise_info_messages(monkeypatch): fake_apprise = _FakeAppriseModule() - fake_apprise.client.notify_info_messages = [ - "Sent Pushover notification to ALL_DEVICES." - ] + fake_apprise.client.notify_info_messages = ["Sent Pushover notification to ALL_DEVICES."] monkeypatch.setattr(notifications_module, "apprise", fake_apprise) info_messages: list[str] = [] @@ -359,13 +357,11 @@ def test_dispatch_to_apprise_logs_captured_apprise_warning_messages(monkeypatch) assert result["success"] is False assert any( - "pover: apprise.plugins.pushover: Failed to send Pushover notification" - in detail + "pover: apprise.plugins.pushover: Failed to send Pushover notification" in detail for detail in result.get("details", []) ) assert any( - "Apprise source [apprise.plugins.pushover]: Failed to send Pushover notification" - in msg + "Apprise source [apprise.plugins.pushover]: Failed to send Pushover notification" in msg for msg in warning_messages ) @@ -396,7 +392,14 @@ def test_dispatch_to_apprise_logs_add_exception_at_debug_with_trace(monkeypatch) assert result["details"] == ["ntfys: route registration failed (RuntimeError: add exploded)"] assert any( "Apprise route registration raised RuntimeError" in (message % args if args else message) - and kwargs.get("exc_info") is True + and kwargs.get("exc_info") + == ( + RuntimeError, + fake_apprise.client.instantiate_exceptions_by_url["ntfys://ntfy.sh/shelfmark"], + fake_apprise.client.instantiate_exceptions_by_url[ + "ntfys://ntfy.sh/shelfmark" + ].__traceback__, + ) for message, args, kwargs in debug_calls ) @@ -427,7 +430,12 @@ def test_dispatch_to_apprise_logs_notify_exception_at_debug_with_trace(monkeypat assert result["details"] == ["ntfys: notify raised RuntimeError: notify exploded"] assert any( "Apprise notify raised RuntimeError" in (message % args if args else message) - and kwargs.get("exc_info") is True + and kwargs.get("exc_info") + == ( + RuntimeError, + fake_apprise.client.notify_exceptions_by_url["ntfys://ntfy.sh/shelfmark"], + fake_apprise.client.notify_exceptions_by_url["ntfys://ntfy.sh/shelfmark"].__traceback__, + ) for message, args, kwargs in debug_calls ) @@ -573,7 +581,10 @@ def test_resolve_user_routes_expands_multiselect_event_rows(monkeypatch): if key != "USER_NOTIFICATION_ROUTES" or user_id != 7: return default return [ - {"event": ["download_complete", "request_fulfilled"], "url": "ntfys://ntfy.sh/user-main"}, + { + "event": ["download_complete", "request_fulfilled"], + "url": "ntfys://ntfy.sh/user-main", + }, {"event": ["all", "download_failed"], "url": "ntfys://ntfy.sh/user-all"}, ] diff --git a/tests/core/test_user_db.py b/tests/core/test_user_db.py index 7c4e5a7f..564aab52 100644 --- a/tests/core/test_user_db.py +++ b/tests/core/test_user_db.py @@ -40,9 +40,7 @@ class TestUserDBInitialization: def test_initialize_creates_users_table(self, user_db, db_path): conn = sqlite3.connect(db_path) - cursor = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='users'" - ) + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") assert cursor.fetchone() is not None conn.close() @@ -193,9 +191,7 @@ class TestUserDBInitialization: columns = conn.execute("PRAGMA table_info(users)").fetchall() assert "auth_source" in {str(c["name"]) for c in columns} - rows = conn.execute( - "SELECT username, auth_source FROM users ORDER BY username" - ).fetchall() + rows = conn.execute("SELECT username, auth_source FROM users ORDER BY username").fetchall() by_username = {r["username"]: r["auth_source"] for r in rows} assert by_username["local_admin"] == "builtin" assert by_username["oidc_user"] == "oidc" @@ -239,9 +235,7 @@ class TestUserDBInitialization: db.initialize() conn = sqlite3.connect(db_path) - user_row = conn.execute( - "SELECT username, email FROM users WHERE id = 1" - ).fetchone() + user_row = conn.execute("SELECT username, email FROM users WHERE id = 1").fetchone() settings_row = conn.execute( "SELECT settings_json FROM user_settings WHERE user_id = 1" ).fetchone() @@ -384,7 +378,9 @@ class TestUserDBInitialization: ); """ ) - conn.execute("INSERT INTO users (id, username, role) VALUES (?, ?, ?)", (1, "legacy-user", "user")) + conn.execute( + "INSERT INTO users (id, username, role) VALUES (?, ?, ?)", (1, "legacy-user", "user") + ) conn.execute( """ INSERT INTO download_requests ( @@ -399,7 +395,16 @@ class TestUserDBInitialization: ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (11, 1, "fulfilled", "complete", "ebook", "book", "request_book", '{"title":"Legacy Book"}'), + ( + 11, + 1, + "fulfilled", + "complete", + "ebook", + "book", + "request_book", + '{"title":"Legacy Book"}', + ), ) conn.execute( """ @@ -476,10 +481,14 @@ class TestUserDBInitialization: assert view_state_table is not None # No retroactive copy from legacy activity tables in the no-backfill plan. - history_count = conn.execute("SELECT COUNT(*) AS count FROM download_history").fetchone()["count"] + history_count = conn.execute("SELECT COUNT(*) AS count FROM download_history").fetchone()[ + "count" + ] assert history_count == 0 - legacy_activity_rows = conn.execute("SELECT COUNT(*) AS count FROM activity_log").fetchone()["count"] + legacy_activity_rows = conn.execute( + "SELECT COUNT(*) AS count FROM activity_log" + ).fetchone()["count"] legacy_dismissal_rows = conn.execute( "SELECT COUNT(*) AS count FROM activity_dismissals" ).fetchone()["count"] @@ -705,7 +714,9 @@ class TestDownloadRequests: def test_create_request_rejects_release_level_without_release_data(self, user_db): user = user_db.create_user(username="alice") - with pytest.raises(ValueError, match="request_level=release requires non-null release_data"): + with pytest.raises( + ValueError, match="request_level=release requires non-null release_data" + ): user_db.create_request( user_id=user["id"], content_type="ebook", @@ -731,7 +742,7 @@ class TestDownloadRequests: def test_create_request_rejects_non_object_release_data(self, user_db): user = user_db.create_user(username="alice") - with pytest.raises(ValueError, match="release_data must be an object when provided"): + with pytest.raises(TypeError, match="release_data must be an object when provided"): user_db.create_request( user_id=user["id"], content_type="ebook", @@ -883,7 +894,7 @@ class TestDownloadRequests: release_data=self._release_data(), ) - with pytest.raises(ValueError, match="release_data must be an object when provided"): + with pytest.raises(TypeError, match="release_data must be an object when provided"): user_db.update_request(created["id"], release_data="not-an-object") def test_reopen_failed_request_resets_fulfilled_request_for_reapproval(self, user_db): diff --git a/tests/core/test_websocket_manager.py b/tests/core/test_websocket_manager.py index 51a9bf87..ee5cbfcf 100644 --- a/tests/core/test_websocket_manager.py +++ b/tests/core/test_websocket_manager.py @@ -20,6 +20,22 @@ def test_join_user_room_without_db_user_id_does_not_join_admin_room(monkeypatch) assert manager._user_rooms == {} +def test_room_helpers_accept_positional_scope_args(monkeypatch): + joined: list[tuple[str, str | None]] = [] + + monkeypatch.setattr( + websocket_module, + "join_room", + lambda room, sid=None: joined.append((room, sid)), + ) + + manager = WebSocketManager() + manager.join_user_room("sid-1", False, 7) + manager.sync_user_room("sid-2", True, None) + + assert joined == [("user_7", "sid-1"), ("admins", "sid-2")] + + def test_sync_user_room_moves_sid_between_rooms(monkeypatch): joined: list[tuple[str, str | None]] = [] left: list[tuple[str, str | None]] = [] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 0e6e4d0a..c980c598 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -2,23 +2,32 @@ E2E Test Configuration and Fixtures. These tests require the full application stack to be running. -Run with: docker exec test-cwabd python3 -m pytest tests/e2e/ -v -m e2e +Run with: uv run pytest tests/e2e/ -v -m e2e """ +from __future__ import annotations + import os import time -from typing import Generator, List, Optional +from contextlib import suppress from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING import pytest import requests +if TYPE_CHECKING: + from collections.abc import Iterator + # Default test configuration DEFAULT_BASE_URL = "http://localhost:8084" DEFAULT_TIMEOUT = 10 POLL_INTERVAL = 2 DOWNLOAD_TIMEOUT = 300 # 5 minutes max for downloads +E2E_USERNAME_ENV = "E2E_USERNAME" +E2E_PASSWORD_ENV = "E2E_PASSWORD" @dataclass @@ -63,12 +72,113 @@ class APIClient: return False +def _get_auth_state(client: APIClient) -> dict[str, object]: + """Read the live server auth state for auth-sensitive E2E tests.""" + try: + response = client.get("/api/auth/check") + except requests.exceptions.RequestException: + return {} + + if response.status_code != 200: + return {} + + try: + payload = response.json() + except ValueError: + return {} + + return payload if isinstance(payload, dict) else {} + + +def _login_with_env_credentials(client: APIClient) -> bool: + """Try authenticating an E2E client with env-provided credentials.""" + username = os.environ.get(E2E_USERNAME_ENV, "").strip() + password = os.environ.get(E2E_PASSWORD_ENV, "") + if not username or not password: + return False + + response = client.post( + "/api/auth/login", + json={ + "username": username, + "password": password, + "remember_me": False, + }, + ) + return response.status_code == 200 + + +def _is_explicit_e2e_run(markexpr: str, args: list[str]) -> bool: + """Detect when pytest was invoked specifically to exercise E2E coverage.""" + normalized_markexpr = (markexpr or "").strip() + if "e2e" in normalized_markexpr and "not e2e" not in normalized_markexpr: + return True + + target_args = [arg for arg in args if not arg.startswith("-")] + if not target_args: + return False + + for arg in target_args: + base = arg.split("::", maxsplit=1)[0] + parts = Path(base).parts + if "tests" not in parts: + return False + + tests_index = parts.index("tests") + if len(parts) <= tests_index + 1 or parts[tests_index + 1] != "e2e": + return False + + return True + + +def _require_authenticated_client(client: APIClient, *, strict: bool) -> APIClient: + """Require an authenticated client for protected-route E2E tests.""" + auth_state = _get_auth_state(client) + if not auth_state or not auth_state.get("auth_required"): + return client + + if auth_state.get("authenticated"): + return client + + username = os.environ.get(E2E_USERNAME_ENV, "").strip() + password = os.environ.get(E2E_PASSWORD_ENV, "") + if not username or not password: + message = ( + "Live server requires authentication for this E2E test. " + f"Set {E2E_USERNAME_ENV}/{E2E_PASSWORD_ENV} or run against a no-auth instance." + ) + if strict: + pytest.fail(message) + pytest.skip(message) + + if not _login_with_env_credentials(client): + message = ( + "Failed to authenticate the E2E client with " + f"{E2E_USERNAME_ENV}/{E2E_PASSWORD_ENV}. " + "Check the credentials or run against a no-auth instance." + ) + if strict: + pytest.fail(message) + pytest.skip(message) + + refreshed_auth_state = _get_auth_state(client) + if refreshed_auth_state.get("authenticated"): + return client + + message = ( + "Login request completed but the live server still reports an unauthenticated session." + ) + if strict: + pytest.fail(message) + pytest.skip(message) + + @dataclass class DownloadTracker: """Tracks downloads for cleanup after tests.""" client: APIClient - queued_ids: List[str] = field(default_factory=list) + queued_ids: list[str] = field(default_factory=list) def track(self, book_id: str) -> str: """Track a book ID for cleanup.""" @@ -78,18 +188,16 @@ class DownloadTracker: def cleanup(self) -> None: """Cancel all tracked downloads.""" for book_id in self.queued_ids: - try: + with suppress(Exception): self.client.delete(f"/api/download/{book_id}/cancel") - except Exception: - pass # Best effort cleanup self.queued_ids.clear() def wait_for_status( self, book_id: str, - target_states: List[str], + target_states: list[str], timeout: int = DOWNLOAD_TIMEOUT, - ) -> Optional[dict]: + ) -> dict | None: """ Poll status until book reaches one of the target states. @@ -141,32 +249,55 @@ def base_url() -> str: @pytest.fixture(scope="session") -def api_client(base_url: str) -> Generator[APIClient, None, None]: - """Create an API client for the test session.""" +def healthy_base_url(base_url: str) -> str: + """Ensure the live server is reachable before creating per-test clients.""" client = APIClient(base_url=base_url) + try: + if not client.wait_for_health(): + pytest.skip("Server not available - ensure the app is running") + return base_url + finally: + client.session.close() - # Wait for server to be healthy - if not client.wait_for_health(): - pytest.skip("Server not available - ensure the app is running") +@pytest.fixture +def api_client(healthy_base_url: str) -> Iterator[APIClient]: + """Create a fresh API client for each E2E test.""" + client = APIClient(base_url=healthy_base_url) yield client - - # Cleanup session client.session.close() @pytest.fixture -def download_tracker(api_client: APIClient) -> Generator[DownloadTracker, None, None]: +def protected_api_client(api_client: APIClient, request: pytest.FixtureRequest) -> APIClient: + """Create an authenticated client for protected-route E2E tests.""" + strict = _is_explicit_e2e_run( + getattr(request.config.option, "markexpr", ""), + list(getattr(request.config, "args", [])), + ) + return _require_authenticated_client(api_client, strict=strict) + + +@pytest.fixture +def download_tracker(request: pytest.FixtureRequest) -> Iterator[DownloadTracker]: """Create a download tracker that cleans up after each test.""" - tracker = DownloadTracker(client=api_client) + client_fixture_name = ( + "protected_api_client" if "protected_api_client" in request.fixturenames else "api_client" + ) + client = request.getfixturevalue(client_fixture_name) + tracker = DownloadTracker(client=client) yield tracker tracker.cleanup() @pytest.fixture(scope="session") -def server_config(api_client: APIClient) -> dict: +def server_config(healthy_base_url: str) -> dict: """Get server configuration.""" - resp = api_client.get("/api/config") - if resp.status_code != 200: - return {} - return resp.json() + client = APIClient(base_url=healthy_base_url) + try: + resp = client.get("/api/config") + if resp.status_code != 200: + return {} + return resp.json() + finally: + client.session.close() diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 0563a420..15ee703e 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -3,7 +3,7 @@ E2E API Tests. Tests the full application flow through the HTTP API. -Run with: docker exec test-cwabd python3 -m pytest tests/e2e/ -v -m e2e +Run with: uv run pytest tests/e2e/ -v -m e2e """ import pytest @@ -36,9 +36,9 @@ class TestHealthEndpoint: class TestConfigEndpoint: """Tests for the configuration endpoint.""" - def test_config_returns_expected_fields(self, api_client: APIClient): + def test_config_returns_expected_fields(self, protected_api_client: APIClient): """Test that config includes expected configuration fields.""" - resp = api_client.get("/api/config") + resp = protected_api_client.get("/api/config") assert resp.status_code == 200 data = resp.json() @@ -47,9 +47,9 @@ class TestConfigEndpoint: # Should have some standard config fields assert "supported_formats" in data or "book_languages" in data - def test_config_returns_supported_formats(self, api_client: APIClient): + def test_config_returns_supported_formats(self, protected_api_client: APIClient): """Test that config includes supported formats.""" - resp = api_client.get("/api/config") + resp = protected_api_client.get("/api/config") data = resp.json() assert "supported_formats" in data @@ -63,17 +63,17 @@ class TestConfigEndpoint: class TestReleaseSourcesEndpoint: """Tests for the release sources endpoint.""" - def test_release_sources_returns_list(self, api_client: APIClient): + def test_release_sources_returns_list(self, protected_api_client: APIClient): """Test that release sources endpoint returns available sources.""" - resp = api_client.get("/api/release-sources") + resp = protected_api_client.get("/api/release-sources") assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) - def test_release_sources_have_required_fields(self, api_client: APIClient): + def test_release_sources_have_required_fields(self, protected_api_client: APIClient): """Test that each release source has required fields.""" - resp = api_client.get("/api/release-sources") + resp = protected_api_client.get("/api/release-sources") data = resp.json() for source in data: @@ -85,18 +85,18 @@ class TestReleaseSourcesEndpoint: class TestMetadataProvidersEndpoint: """Tests for the metadata providers endpoint.""" - def test_providers_returns_data(self, api_client: APIClient): + def test_providers_returns_data(self, protected_api_client: APIClient): """Test that providers endpoint returns provider data.""" - resp = api_client.get("/api/metadata/providers") + resp = protected_api_client.get("/api/metadata/providers") assert resp.status_code == 200 data = resp.json() # May be list or dict depending on implementation assert isinstance(data, (list, dict)) - def test_providers_have_required_fields(self, api_client: APIClient): + def test_providers_have_required_fields(self, protected_api_client: APIClient): """Test that each provider has required fields.""" - resp = api_client.get("/api/metadata/providers") + resp = protected_api_client.get("/api/metadata/providers") data = resp.json() # Handle both list and dict formats @@ -115,16 +115,16 @@ class TestMetadataProvidersEndpoint: class TestMetadataSearch: """Tests for metadata search functionality.""" - def test_search_requires_query(self, api_client: APIClient): + def test_search_requires_query(self, protected_api_client: APIClient): """Test that search requires a query parameter.""" - resp = api_client.get("/api/metadata/search") + resp = protected_api_client.get("/api/metadata/search") # Should return error for missing query assert resp.status_code in [400, 422] - def test_search_returns_results(self, api_client: APIClient): + def test_search_returns_results(self, protected_api_client: APIClient): """Test that search returns results for a known book.""" - resp = api_client.get("/api/metadata/search", params={"query": "1984 Orwell"}) + resp = protected_api_client.get("/api/metadata/search", params={"query": "1984 Orwell"}) # May return 200 with results or 503 if provider unavailable if resp.status_code == 200: @@ -132,10 +132,10 @@ class TestMetadataSearch: # Response may be list directly, or dict with results key assert "results" in data or isinstance(data, list) or "query" in data - def test_search_with_provider_filter(self, api_client: APIClient): + def test_search_with_provider_filter(self, protected_api_client: APIClient): """Test searching with a specific provider.""" # Get available providers first - providers_resp = api_client.get("/api/metadata/providers") + providers_resp = protected_api_client.get("/api/metadata/providers") if providers_resp.status_code != 200: pytest.skip("Could not get providers") @@ -149,7 +149,11 @@ class TestMetadataSearch: if providers_data: first_key = list(providers_data.keys())[0] provider_info = providers_data[first_key] - provider_name = provider_info.get("name", first_key) if isinstance(provider_info, dict) else first_key + provider_name = ( + provider_info.get("name", first_key) + if isinstance(provider_info, dict) + else first_key + ) else: pytest.skip("No providers available") else: @@ -159,7 +163,7 @@ class TestMetadataSearch: if not provider_name: pytest.skip("Could not determine provider name") - resp = api_client.get( + resp = protected_api_client.get( "/api/metadata/search", params={"query": "Moby Dick", "provider": provider_name}, ) @@ -172,18 +176,18 @@ class TestMetadataSearch: class TestStatusEndpoint: """Tests for the status endpoint.""" - def test_status_returns_categories(self, api_client: APIClient): + def test_status_returns_categories(self, protected_api_client: APIClient): """Test that status endpoint returns expected categories.""" - resp = api_client.get("/api/status") + resp = protected_api_client.get("/api/status") assert resp.status_code == 200 data = resp.json() # Should have standard status categories assert isinstance(data, dict) - def test_active_downloads_endpoint(self, api_client: APIClient): + def test_active_downloads_endpoint(self, protected_api_client: APIClient): """Test the active downloads endpoint.""" - resp = api_client.get("/api/downloads/active") + resp = protected_api_client.get("/api/downloads/active") assert resp.status_code == 200 data = resp.json() @@ -194,9 +198,9 @@ class TestStatusEndpoint: class TestQueueEndpoint: """Tests for queue management endpoints.""" - def test_queue_order_returns_data(self, api_client: APIClient): + def test_queue_order_returns_data(self, protected_api_client: APIClient): """Test that queue order endpoint returns queue data.""" - resp = api_client.get("/api/queue/order") + resp = protected_api_client.get("/api/queue/order") assert resp.status_code == 200 data = resp.json() @@ -207,13 +211,14 @@ class TestQueueEndpoint: else: assert isinstance(data, list) + @pytest.mark.e2e class TestSettingsEndpoint: """Tests for settings endpoints.""" - def test_settings_returns_tabs(self, api_client: APIClient): + def test_settings_returns_tabs(self, protected_api_client: APIClient): """Test that settings endpoint returns tab structure.""" - resp = api_client.get("/api/settings") + resp = protected_api_client.get("/api/settings") # Settings may be disabled if config dir not writable if resp.status_code == 403: @@ -223,10 +228,10 @@ class TestSettingsEndpoint: data = resp.json() assert isinstance(data, (list, dict)) - def test_get_specific_settings_tab(self, api_client: APIClient): + def test_get_specific_settings_tab(self, protected_api_client: APIClient): """Test getting a specific settings tab.""" # First get available tabs - resp = api_client.get("/api/settings") + resp = protected_api_client.get("/api/settings") if resp.status_code == 403: pytest.skip("Settings disabled") @@ -243,7 +248,7 @@ class TestSettingsEndpoint: if not tab_name: pytest.skip("Could not determine tab name") - resp = api_client.get(f"/api/settings/{tab_name}") + resp = protected_api_client.get(f"/api/settings/{tab_name}") assert resp.status_code in [200, 404] @@ -251,9 +256,9 @@ class TestSettingsEndpoint: class TestDownloadFlow: """Tests for the complete download flow.""" - def test_cancel_nonexistent_download(self, api_client: APIClient): + def test_cancel_nonexistent_download(self, protected_api_client: APIClient): """Test cancelling a download that doesn't exist.""" - resp = api_client.delete("/api/download/nonexistent-id-xyz/cancel") + resp = protected_api_client.delete("/api/download/nonexistent-id-xyz/cancel") # Should handle gracefully (may return 200, 204, or 404) assert resp.status_code in [200, 204, 404] @@ -263,21 +268,21 @@ class TestDownloadFlow: class TestReleaseDownloadFlow: """Tests for the release-based download flow (new API).""" - def test_release_download_requires_source_id(self, api_client: APIClient): + def test_release_download_requires_source_id(self, protected_api_client: APIClient): """Test that release download requires source_id.""" - resp = api_client.post("/api/releases/download", json={}) + resp = protected_api_client.post("/api/releases/download", json={}) assert resp.status_code == 400 data = resp.json() assert "error" in data def test_release_download_with_minimal_data( - self, api_client: APIClient, download_tracker: DownloadTracker + self, protected_api_client: APIClient, download_tracker: DownloadTracker ): """Test queueing a release with minimal valid data.""" # This will queue but likely fail during download (no real source) test_id = "e2e-test-release-minimal" - resp = api_client.post( + resp = protected_api_client.post( "/api/releases/download", json={ "source": "test_source", @@ -292,12 +297,12 @@ class TestReleaseDownloadFlow: assert data.get("status") == "queued" def test_cancel_release_with_slash_id( - self, api_client: APIClient, download_tracker: DownloadTracker + self, protected_api_client: APIClient, download_tracker: DownloadTracker ): """Cancelling/clearing should work for IDs containing slashes.""" test_id = "e2e-test-release/with-slash" - resp = api_client.post( + resp = protected_api_client.post( "/api/releases/download", json={ "source": "test_source", @@ -311,7 +316,7 @@ class TestReleaseDownloadFlow: download_tracker.track(test_id) - cancel_resp = api_client.delete(f"/api/download/{test_id}/cancel") + cancel_resp = protected_api_client.delete(f"/api/download/{test_id}/cancel") assert cancel_resp.status_code in [200, 204] @@ -319,17 +324,17 @@ class TestReleaseDownloadFlow: class TestReleasesSearch: """Tests for searching releases.""" - def test_releases_requires_params(self, api_client: APIClient): + def test_releases_requires_params(self, protected_api_client: APIClient): """Test that releases endpoint requires provider and book_id.""" - resp = api_client.get("/api/releases") + resp = protected_api_client.get("/api/releases") assert resp.status_code == 400 data = resp.json() assert "error" in data - def test_releases_with_invalid_provider(self, api_client: APIClient): + def test_releases_with_invalid_provider(self, protected_api_client: APIClient): """Test releases with invalid provider.""" - resp = api_client.get( + resp = protected_api_client.get( "/api/releases", params={"provider": "nonexistent_provider", "book_id": "123"}, ) @@ -343,9 +348,9 @@ class TestReleasesSearch: class TestCoverProxy: """Tests for the cover image proxy.""" - def test_cover_without_url_returns_error(self, api_client: APIClient): + def test_cover_without_url_returns_error(self, protected_api_client: APIClient): """Test that cover endpoint without URL returns error.""" - resp = api_client.get("/api/covers/test-id") + resp = protected_api_client.get("/api/covers/test-id") # Should return error for missing URL assert resp.status_code in [400, 404] @@ -355,15 +360,15 @@ class TestCoverProxy: class TestDirectSourceQueryEndpoint: """Tests for direct-mode source query search on the shared releases API.""" - def test_direct_source_query_requires_browse_context(self, api_client: APIClient): + def test_direct_source_query_requires_browse_context(self, protected_api_client: APIClient): """Source query mode requires a query or browse filters.""" - resp = api_client.get("/api/releases", params={"source": "direct_download"}) + resp = protected_api_client.get("/api/releases", params={"source": "direct_download"}) assert resp.status_code in [400, 422] - def test_direct_source_query_returns_results(self, api_client: APIClient): + def test_direct_source_query_returns_results(self, protected_api_client: APIClient): """Direct mode uses /api/releases source query mode.""" - resp = api_client.get( + resp = protected_api_client.get( "/api/releases", params={"source": "direct_download", "query": "Pride Prejudice"}, ) @@ -379,9 +384,11 @@ class TestDirectSourceQueryEndpoint: class TestSourceRecordEndpoint: """Tests for source-native record lookup on the shared source-record API.""" - def test_source_record_invalid_id(self, api_client: APIClient): + def test_source_record_invalid_id(self, protected_api_client: APIClient): """Unknown source records should return a not-found style response.""" - resp = api_client.get("/api/release-sources/direct_download/records/invalid-id-xyz") + resp = protected_api_client.get( + "/api/release-sources/direct_download/records/invalid-id-xyz" + ) # Should return 404 or error assert resp.status_code in [404, 500, 503] diff --git a/tests/e2e/test_auth_flow.py b/tests/e2e/test_auth_flow.py index 41ffd419..50113f28 100644 --- a/tests/e2e/test_auth_flow.py +++ b/tests/e2e/test_auth_flow.py @@ -4,7 +4,7 @@ E2E tests for authentication endpoints. Tests the full authentication flow including login, logout, and auth check with various authentication modes. -Run with: docker exec test-cwabd python3 -m pytest tests/e2e/ -v -m e2e +Run with: uv run pytest tests/e2e/ -v -m e2e """ import pytest @@ -63,29 +63,26 @@ class TestAuthenticationFlow: def test_login_endpoint_exists(self, api_client: APIClient): """Test that login endpoint is accessible.""" - resp = api_client.post("/api/auth/login", json={ - "username": "test", - "password": "test", - "remember_me": False - }) + resp = api_client.post( + "/api/auth/login", json={"username": "test", "password": "test", "remember_me": False} + ) - # Should return some response (may be success or error depending on config) - assert resp.status_code in [200, 401, 403] + # Should return some response (may be success, auth error, or rate limit) + assert resp.status_code in [200, 401, 403, 429] def test_login_with_no_auth_succeeds(self, api_client: APIClient): """Test that login succeeds when no authentication is required.""" # First check if auth is required auth_check = api_client.get("/api/auth/check") auth_data = auth_check.json() - + if not auth_data.get("auth_required"): # Try logging in - resp = api_client.post("/api/auth/login", json={ - "username": "anyuser", - "password": "anypass", - "remember_me": False - }) - + resp = api_client.post( + "/api/auth/login", + json={"username": "anyuser", "password": "anypass", "remember_me": False}, + ) + # Should succeed assert resp.status_code == 200 data = resp.json() @@ -101,10 +98,10 @@ class TestProxyAuthentication: # Check current auth mode auth_check = api_client.get("/api/auth/check") auth_data = auth_check.json() - + if auth_data.get("auth_mode") != "proxy": pytest.skip("Proxy authentication not configured") - + # Make a request with proxy auth header # Note: In real deployment, these headers would be set by the proxy resp = api_client.get("/api/config", headers={"X-Auth-User": "proxyuser"}) @@ -120,10 +117,10 @@ class TestProxyAuthentication: # Check current auth mode auth_check = api_client.get("/api/auth/check") auth_data = auth_check.json() - + if auth_data.get("auth_mode") != "proxy": pytest.skip("Proxy authentication not configured") - + # Check for logout URL in auth check response if "logout_url" in auth_data: assert isinstance(auth_data["logout_url"], str) @@ -139,14 +136,14 @@ class TestBuiltinAuthentication: # Check current auth mode auth_check = api_client.get("/api/auth/check") auth_data = auth_check.json() - + if auth_data.get("auth_mode") != "builtin": pytest.skip("Built-in authentication not configured") - + if not auth_data.get("authenticated"): # Attempt to access protected endpoint without authentication resp = api_client.get("/api/config") - + # Should be blocked assert resp.status_code == 401 @@ -155,19 +152,18 @@ class TestBuiltinAuthentication: # Check current auth mode auth_check = api_client.get("/api/auth/check") auth_data = auth_check.json() - + if auth_data.get("auth_mode") != "builtin": pytest.skip("Built-in authentication not configured") - + # Try logging in with invalid credentials - resp = api_client.post("/api/auth/login", json={ - "username": "invalid_user", - "password": "wrong_password", - "remember_me": False - }) - - # Should fail - assert resp.status_code in [401, 403] + resp = api_client.post( + "/api/auth/login", + json={"username": "invalid_user", "password": "wrong_password", "remember_me": False}, + ) + + # Should fail, or be rate-limited on a live stack after repeated attempts + assert resp.status_code in [401, 403, 429] data = resp.json() assert data.get("success") is not True @@ -181,7 +177,7 @@ class TestCalibreWebAuthentication: # Check current auth mode auth_check = api_client.get("/api/auth/check") auth_data = auth_check.json() - + if auth_data.get("auth_mode") == "cwa": # CWA mode is active assert auth_data["auth_mode"] == "cwa" @@ -199,13 +195,13 @@ class TestAdminAccess: # Check current auth status auth_check = api_client.get("/api/auth/check") auth_data = auth_check.json() - + # If auth is required and user is not admin if auth_data.get("auth_required") and auth_data.get("authenticated"): if not auth_data.get("is_admin"): # Try accessing settings resp = api_client.get("/api/settings") - + # May be blocked with 403 if admin-only # Or allowed if settings are not restricted assert resp.status_code in [200, 403] @@ -215,13 +211,13 @@ class TestAdminAccess: # Check current auth status auth_check = api_client.get("/api/auth/check") auth_data = auth_check.json() - + # If auth is required and user is not admin if auth_data.get("auth_required") and auth_data.get("authenticated"): if not auth_data.get("is_admin"): # Try accessing onboarding resp = api_client.get("/api/onboarding") - + # May be blocked with 403 if admin-only # Or allowed if settings are not restricted assert resp.status_code in [200, 403] @@ -236,19 +232,19 @@ class TestAuthenticationWorkflow: # Check initial auth status auth_check = api_client.get("/api/auth/check") initial_auth = auth_check.json() - + # If no auth required, skip this test if not initial_auth.get("auth_required"): pytest.skip("No authentication required") - + # Try logout first to clear any existing session logout_resp = api_client.post("/api/auth/logout") assert logout_resp.status_code == 200 - + # Check we're logged out auth_check = api_client.get("/api/auth/check") post_logout_auth = auth_check.json() - + # For builtin/cwa auth, should not be authenticated # For proxy auth, depends on proxy configuration if initial_auth.get("auth_mode") in ["builtin", "cwa"]: @@ -260,18 +256,18 @@ class TestAuthenticationWorkflow: resp1 = api_client.get("/api/auth/check") resp2 = api_client.get("/api/auth/check") resp3 = api_client.get("/api/auth/check") - + data1 = resp1.json() data2 = resp2.json() data3 = resp3.json() - + # All should succeed assert resp1.status_code == 200 assert resp2.status_code == 200 assert resp3.status_code == 200 - + # Auth mode should be consistent assert data1["auth_mode"] == data2["auth_mode"] == data3["auth_mode"] - + # Auth required should be consistent assert data1["auth_required"] == data2["auth_required"] == data3["auth_required"] diff --git a/tests/e2e/test_conftest_helpers.py b/tests/e2e/test_conftest_helpers.py new file mode 100644 index 00000000..bd4f8136 --- /dev/null +++ b/tests/e2e/test_conftest_helpers.py @@ -0,0 +1,96 @@ +import pytest +from _pytest.outcomes import Failed, Skipped + +from tests.e2e import conftest as e2e_conftest + + +def test_require_authenticated_client_allows_public_server( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = e2e_conftest.APIClient(base_url="http://example.com") + monkeypatch.setattr( + e2e_conftest, + "_get_auth_state", + lambda _: {"auth_required": False}, + ) + + try: + assert e2e_conftest._require_authenticated_client(client, strict=True) is client + finally: + client.session.close() + + +def test_require_authenticated_client_fails_without_env_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = e2e_conftest.APIClient(base_url="http://example.com") + monkeypatch.setattr( + e2e_conftest, + "_get_auth_state", + lambda _: {"auth_required": True, "authenticated": False}, + ) + monkeypatch.delenv(e2e_conftest.E2E_USERNAME_ENV, raising=False) + monkeypatch.delenv(e2e_conftest.E2E_PASSWORD_ENV, raising=False) + + try: + with pytest.raises(Failed, match="requires authentication"): + e2e_conftest._require_authenticated_client(client, strict=True) + finally: + client.session.close() + + +def test_require_authenticated_client_skips_without_env_credentials_when_not_strict( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = e2e_conftest.APIClient(base_url="http://example.com") + monkeypatch.setattr( + e2e_conftest, + "_get_auth_state", + lambda _: {"auth_required": True, "authenticated": False}, + ) + monkeypatch.delenv(e2e_conftest.E2E_USERNAME_ENV, raising=False) + monkeypatch.delenv(e2e_conftest.E2E_PASSWORD_ENV, raising=False) + + try: + with pytest.raises(Skipped, match="requires authentication"): + e2e_conftest._require_authenticated_client(client, strict=False) + finally: + client.session.close() + + +def test_require_authenticated_client_logs_in_when_credentials_are_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = e2e_conftest.APIClient(base_url="http://example.com") + auth_states = iter( + [ + {"auth_required": True, "authenticated": False}, + {"auth_required": True, "authenticated": True}, + ] + ) + monkeypatch.setattr(e2e_conftest, "_get_auth_state", lambda _: next(auth_states)) + monkeypatch.setattr(e2e_conftest, "_login_with_env_credentials", lambda _: True) + monkeypatch.setenv(e2e_conftest.E2E_USERNAME_ENV, "user") + monkeypatch.setenv(e2e_conftest.E2E_PASSWORD_ENV, "password") + + try: + assert e2e_conftest._require_authenticated_client(client, strict=True) is client + finally: + client.session.close() + + +def test_is_explicit_e2e_run_detects_e2e_path_selection() -> None: + assert e2e_conftest._is_explicit_e2e_run("", ["tests/e2e/"]) is True + assert ( + e2e_conftest._is_explicit_e2e_run("", ["tests/e2e/test_api.py::TestConfigEndpoint"]) is True + ) + + +def test_is_explicit_e2e_run_detects_markexpr_selection() -> None: + assert e2e_conftest._is_explicit_e2e_run("e2e", ["tests/"]) is True + assert e2e_conftest._is_explicit_e2e_run("slow and e2e", ["tests/"]) is True + + +def test_is_explicit_e2e_run_treats_general_suite_as_non_strict() -> None: + assert e2e_conftest._is_explicit_e2e_run("", ["tests/"]) is False + assert e2e_conftest._is_explicit_e2e_run("not integration and not e2e", ["tests/"]) is False diff --git a/tests/e2e/test_download_flow.py b/tests/e2e/test_download_flow.py index 12e66a4f..8bb8a99f 100644 --- a/tests/e2e/test_download_flow.py +++ b/tests/e2e/test_download_flow.py @@ -4,7 +4,7 @@ E2E Download Flow Tests. These tests verify the complete download journey from search to file retrieval. They require external services to be available and may take longer to run. -Run with: docker exec test-cwabd python3 -m pytest tests/e2e/test_download_flow.py -v -m e2e +Run with: uv run pytest tests/e2e/test_download_flow.py -v -m e2e """ import os @@ -30,7 +30,9 @@ def _find_available_provider(api_client: APIClient) -> str | None: provider_names = list(providers_data.keys()) else: # List format - provider_names = [p.get("name") for p in providers_data if isinstance(p, dict) and p.get("name")] + provider_names = [ + p.get("name") for p in providers_data if isinstance(p, dict) and p.get("name") + ] for name in provider_names: if name: @@ -65,15 +67,15 @@ def _find_available_release_source(api_client: APIClient) -> str | None: class TestMetadataToReleaseFlow: """Test the flow from metadata search to release listing.""" - def test_search_to_releases_flow(self, api_client: APIClient): + def test_search_to_releases_flow(self, protected_api_client: APIClient): """Test searching metadata then finding releases.""" # Find a working provider - provider = _find_available_provider(api_client) + provider = _find_available_provider(protected_api_client) if not provider: pytest.skip("No metadata providers available") # Search for a public domain book - search_resp = api_client.get( + search_resp = protected_api_client.get( "/api/metadata/search", params={"query": "Moby Dick Herman Melville", "provider": provider}, timeout=30, @@ -102,7 +104,7 @@ class TestMetadataToReleaseFlow: assert book_id, "Search result missing ID" # Now search for releases - releases_resp = api_client.get( + releases_resp = protected_api_client.get( "/api/releases", params={ "provider": provider, @@ -135,16 +137,16 @@ class TestFullDownloadJourney: """ def test_complete_download_flow( - self, api_client: APIClient, download_tracker: DownloadTracker + self, protected_api_client: APIClient, download_tracker: DownloadTracker ): """Test the complete search -> download -> verify flow.""" # Find a working provider - provider = _find_available_provider(api_client) + provider = _find_available_provider(protected_api_client) if not provider: pytest.skip("No metadata providers available") # Search for a public domain book - search_resp = api_client.get( + search_resp = protected_api_client.get( "/api/metadata/search", params={"query": "Pride and Prejudice Jane Austen", "provider": provider}, timeout=30, @@ -170,7 +172,7 @@ class TestFullDownloadJourney: book_id = first_result.get("id") or first_result.get("provider_id") # Get releases - releases_resp = api_client.get( + releases_resp = protected_api_client.get( "/api/releases", params={ "provider": provider, @@ -205,7 +207,7 @@ class TestFullDownloadJourney: source_id = target_release.get("source_id") or target_release.get("id") download_tracker.track(source_id) - queue_resp = api_client.post( + queue_resp = protected_api_client.post( "/api/releases/download", json={ "source": target_release.get("source", "direct_download"), @@ -229,7 +231,7 @@ class TestFullDownloadJourney: if result is None: # Check if it errored - status_resp = api_client.get("/api/status") + status_resp = protected_api_client.get("/api/status") if status_resp.status_code == 200: status_data = status_resp.json() if "error" in status_data and source_id in status_data["error"]: @@ -246,10 +248,10 @@ class TestDirectSourceReleaseFlow: """Test direct-mode search, record lookup, and download via shared release APIs.""" def test_direct_source_search_and_download( - self, api_client: APIClient, download_tracker: DownloadTracker + self, protected_api_client: APIClient, download_tracker: DownloadTracker ): """Test the shared direct-mode source query -> record -> release download flow.""" - search_resp = api_client.get( + search_resp = protected_api_client.get( "/api/releases", params={"source": "direct_download", "query": "Frankenstein Mary Shelley"}, timeout=30, @@ -272,14 +274,14 @@ class TestDirectSourceReleaseFlow: assert source == "direct_download", "Result missing direct source context" assert source_id, "Result missing source_id" - info_resp = api_client.get(f"/api/release-sources/{source}/records/{source_id}") + info_resp = protected_api_client.get(f"/api/release-sources/{source}/records/{source_id}") if info_resp.status_code != 200: pytest.skip(f"Source record endpoint failed: {info_resp.status_code}") # Queue download from the shared release payload download_tracker.track(source_id) - download_resp = api_client.post( + download_resp = protected_api_client.post( "/api/releases/download", json={**first_result, "content_type": "ebook", "search_mode": "direct"}, ) @@ -296,14 +298,14 @@ class TestDownloadCancellation: """Test download cancellation functionality.""" def test_cancel_queued_download( - self, api_client: APIClient, download_tracker: DownloadTracker + self, protected_api_client: APIClient, download_tracker: DownloadTracker ): """Test cancelling a queued download.""" # Queue a fake download test_id = f"cancel-test-{int(time.time())}" download_tracker.track(test_id) - queue_resp = api_client.post( + queue_resp = protected_api_client.post( "/api/releases/download", json={ "source": "test_source", @@ -319,19 +321,19 @@ class TestDownloadCancellation: time.sleep(1) # Cancel it - cancel_resp = api_client.delete(f"/api/download/{test_id}/cancel") + cancel_resp = protected_api_client.delete(f"/api/download/{test_id}/cancel") assert cancel_resp.status_code in [200, 204] def test_cancel_removes_from_queue( - self, api_client: APIClient, download_tracker: DownloadTracker + self, protected_api_client: APIClient, download_tracker: DownloadTracker ): """Test that cancellation removes item from queue.""" test_id = f"cancel-verify-{int(time.time())}" download_tracker.track(test_id) # Queue it - api_client.post( + protected_api_client.post( "/api/releases/download", json={ "source": "test_source", @@ -343,12 +345,12 @@ class TestDownloadCancellation: time.sleep(0.5) # Cancel it - api_client.delete(f"/api/download/{test_id}/cancel") + protected_api_client.delete(f"/api/download/{test_id}/cancel") time.sleep(0.5) # Check it's not in the queue - queue_resp = api_client.get("/api/queue/order") + queue_resp = protected_api_client.get("/api/queue/order") if queue_resp.status_code == 200: queue_order = queue_resp.json() assert test_id not in queue_order @@ -358,15 +360,13 @@ class TestDownloadCancellation: class TestQueuePriority: """Test queue priority functionality.""" - def test_set_priority( - self, api_client: APIClient, download_tracker: DownloadTracker - ): + def test_set_priority(self, protected_api_client: APIClient, download_tracker: DownloadTracker): """Test setting download priority.""" test_id = f"priority-test-{int(time.time())}" download_tracker.track(test_id) # Queue it - queue_resp = api_client.post( + queue_resp = protected_api_client.post( "/api/releases/download", json={ "source": "test_source", @@ -382,7 +382,7 @@ class TestQueuePriority: time.sleep(0.5) # Update priority - priority_resp = api_client.put( + priority_resp = protected_api_client.put( f"/api/queue/{test_id}/priority", json={"priority": 10}, ) diff --git a/tests/e2e/test_prowlarr_flow.py b/tests/e2e/test_prowlarr_flow.py index d8dde286..7f36f5a8 100644 --- a/tests/e2e/test_prowlarr_flow.py +++ b/tests/e2e/test_prowlarr_flow.py @@ -4,7 +4,7 @@ E2E Tests for Prowlarr Integration. These tests verify the Prowlarr release source and download client flow. Requires Prowlarr and a download client (qBittorrent, Transmission, etc.) to be configured. -Run with: docker exec test-cwabd python3 -m pytest tests/e2e/test_prowlarr_flow.py -v -m e2e +Run with: uv run pytest tests/e2e/test_prowlarr_flow.py -v -m e2e """ import time @@ -56,18 +56,18 @@ def _get_first_provider_name(api_client: APIClient) -> str | None: class TestProwlarrConfiguration: """Tests for Prowlarr configuration.""" - def test_prowlarr_in_release_sources(self, api_client: APIClient): + def test_prowlarr_in_release_sources(self, protected_api_client: APIClient): """Test that Prowlarr appears in release sources.""" - resp = api_client.get("/api/release-sources") + resp = protected_api_client.get("/api/release-sources") assert resp.status_code == 200 sources = resp.json() source_names = [s.get("name") for s in sources] assert "prowlarr" in source_names - def test_prowlarr_settings_tab_exists(self, api_client: APIClient): + def test_prowlarr_settings_tab_exists(self, protected_api_client: APIClient): """Test that Prowlarr settings tab exists.""" - resp = api_client.get("/api/settings") + resp = protected_api_client.get("/api/settings") if resp.status_code == 403: pytest.skip("Settings disabled") @@ -95,10 +95,12 @@ class TestProwlarrConfiguration: # Prowlarr settings should exist (may be under different name) prowlarr_tabs = [n for n in tab_names if n and "prowlarr" in n.lower()] # Also check if we can directly access the prowlarr_clients settings - prowlarr_resp = api_client.get("/api/settings/prowlarr_clients") + prowlarr_resp = protected_api_client.get("/api/settings/prowlarr_clients") has_prowlarr_settings = prowlarr_resp.status_code == 200 - assert prowlarr_tabs or has_prowlarr_settings, f"No prowlarr settings found. Tab names: {tab_names}" + assert prowlarr_tabs or has_prowlarr_settings, ( + f"No prowlarr settings found. Tab names: {tab_names}" + ) @pytest.mark.e2e @@ -106,17 +108,17 @@ class TestProwlarrConfiguration: class TestProwlarrSearch: """Tests for searching via Prowlarr.""" - def test_prowlarr_search_with_metadata(self, api_client: APIClient): + def test_prowlarr_search_with_metadata(self, protected_api_client: APIClient): """Test searching Prowlarr with metadata from a provider.""" - if not _is_prowlarr_configured(api_client): + if not _is_prowlarr_configured(protected_api_client): pytest.skip("Prowlarr not configured") - provider = _get_first_provider_name(api_client) + provider = _get_first_provider_name(protected_api_client) if not provider: pytest.skip("No metadata providers") # Search for a book - search_resp = api_client.get( + search_resp = protected_api_client.get( "/api/metadata/search", params={"query": "The Great Gatsby", "provider": provider}, timeout=30, @@ -148,7 +150,7 @@ class TestProwlarrSearch: book_id = book.get("id") or book.get("provider_id") # Now search releases specifically from Prowlarr - releases_resp = api_client.get( + releases_resp = protected_api_client.get( "/api/releases", params={ "provider": provider, @@ -174,9 +176,9 @@ class TestProwlarrSearch: class TestProwlarrClientSettings: """Tests for Prowlarr download client settings.""" - def test_client_settings_structure(self, api_client: APIClient): + def test_client_settings_structure(self, protected_api_client: APIClient): """Test that client settings have expected structure.""" - resp = api_client.get("/api/settings/prowlarr_clients") + resp = protected_api_client.get("/api/settings/prowlarr_clients") if resp.status_code == 403: pytest.skip("Settings disabled") @@ -189,10 +191,10 @@ class TestProwlarrClientSettings: # Should have fields for client configuration assert isinstance(data, (dict, list)) - def test_can_save_client_settings(self, api_client: APIClient): + def test_can_save_client_settings(self, protected_api_client: APIClient): """Test that client settings can be saved.""" # Get current settings - get_resp = api_client.get("/api/settings/prowlarr_clients") + get_resp = protected_api_client.get("/api/settings/prowlarr_clients") if get_resp.status_code in [403, 404]: pytest.skip("Settings not available") @@ -208,7 +210,7 @@ class TestProwlarrClientSettings: if key: values[key] = field.get("value", "") - put_resp = api_client.put( + put_resp = protected_api_client.put( "/api/settings/prowlarr_clients", json=values, ) @@ -222,18 +224,18 @@ class TestProwlarrDownload: """Tests for downloading via Prowlarr.""" def test_queue_prowlarr_release( - self, api_client: APIClient, download_tracker: DownloadTracker + self, protected_api_client: APIClient, download_tracker: DownloadTracker ): """Test queueing a Prowlarr release for download.""" - if not _is_prowlarr_configured(api_client): + if not _is_prowlarr_configured(protected_api_client): pytest.skip("Prowlarr not configured") - provider = _get_first_provider_name(api_client) + provider = _get_first_provider_name(protected_api_client) if not provider: pytest.skip("No providers") # Search metadata - search_resp = api_client.get( + search_resp = protected_api_client.get( "/api/metadata/search", params={"query": "Dracula Bram Stoker", "provider": provider}, timeout=30, @@ -259,7 +261,7 @@ class TestProwlarrDownload: book_id = book.get("id") or book.get("provider_id") # Search Prowlarr releases - releases_resp = api_client.get( + releases_resp = protected_api_client.get( "/api/releases", params={ "provider": provider, @@ -283,7 +285,7 @@ class TestProwlarrDownload: download_tracker.track(source_id) # Queue it - queue_resp = api_client.post( + queue_resp = protected_api_client.post( "/api/releases/download", json={ "source": "prowlarr", @@ -303,7 +305,7 @@ class TestProwlarrDownload: # Wait briefly and check status time.sleep(3) - status_resp = api_client.get("/api/status") + status_resp = protected_api_client.get("/api/status") if status_resp.status_code == 200: status_data = status_resp.json() # Should be in one of the status categories @@ -319,10 +321,10 @@ class TestProwlarrDownload: class TestProwlarrClientConnection: """Tests for testing download client connections.""" - def test_connection_test_action(self, api_client: APIClient): + def test_connection_test_action(self, protected_api_client: APIClient): """Test the connection test action for download clients.""" # This tests the action button functionality in settings - resp = api_client.post( + resp = protected_api_client.post( "/api/settings/prowlarr_clients/action/test_torrent_connection" ) diff --git a/tests/metadata/test_hardcover_lists.py b/tests/metadata/test_hardcover_lists.py index da35a1c5..6e2cd827 100644 --- a/tests/metadata/test_hardcover_lists.py +++ b/tests/metadata/test_hardcover_lists.py @@ -1,3 +1,5 @@ +import pytest + from shelfmark.core.cache import cache_key from shelfmark.metadata_providers import MetadataSearchOptions, SearchResult from shelfmark.metadata_providers.hardcover import ( @@ -223,6 +225,21 @@ class TestHardcoverLists: }, ] + def test_get_book_targets_raises_runtime_error_for_invalid_membership_payload( + self, monkeypatch + ): + provider = HardcoverProvider(api_key="test-token") + + monkeypatch.setattr(provider, "get_user_lists", lambda: []) + monkeypatch.setattr( + provider, + "_execute_query", + lambda query, variables, raise_on_error=False: None, + ) + + with pytest.raises(RuntimeError, match="Hardcover could not load book targets"): + provider.get_book_targets("123") + def test_set_book_target_state_updates_existing_status(self, monkeypatch): provider = HardcoverProvider(api_key="test-token") captured: dict[str, object] = {} diff --git a/tests/metadata/test_hardcover_series_search.py b/tests/metadata/test_hardcover_series_search.py index 389a8b1b..6ba9bfa5 100644 --- a/tests/metadata/test_hardcover_series_search.py +++ b/tests/metadata/test_hardcover_series_search.py @@ -143,6 +143,7 @@ class TestHardcoverSeriesSearch: series_id: int, page: int, limit: int, + *, exclude_compilations: bool, exclude_unreleased: bool, ) -> SearchResult: @@ -203,6 +204,7 @@ class TestHardcoverSeriesSearch: series_id: int, page: int, limit: int, + *, exclude_compilations: bool, exclude_unreleased: bool, ) -> SearchResult: diff --git a/tests/prowlarr/test_client_settings_ssl_verify.py b/tests/prowlarr/test_client_settings_ssl_verify.py index 0f26c25d..3404f9ae 100644 --- a/tests/prowlarr/test_client_settings_ssl_verify.py +++ b/tests/prowlarr/test_client_settings_ssl_verify.py @@ -42,6 +42,40 @@ def test_transmission_settings_test_connection_applies_ssl_verify(monkeypatch): assert mock_http_session.verify is False +def test_qbittorrent_settings_test_connection_returns_failure_for_api_error(monkeypatch): + """qBittorrent settings callback should convert API errors into a failure payload.""" + from shelfmark.core.config import config as config_obj + from shelfmark.download.clients import settings as settings_module + + current_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + } + monkeypatch.setattr(config_obj, "get", make_config_getter(current_values)) + monkeypatch.setattr(settings_module, "get_ssl_verify", lambda _url: True) + + fake_qbittorrentapi = types.ModuleType("qbittorrentapi") + + class FakeAPIError(RuntimeError): + pass + + class FakeClient: + def __init__(self, **_kwargs): + pass + + def auth_log_in(self): + raise FakeAPIError("boom") + + fake_qbittorrentapi.Client = FakeClient + fake_qbittorrentapi.APIError = FakeAPIError + + with patch.dict("sys.modules", {"qbittorrentapi": fake_qbittorrentapi}): + result = settings_module._test_qbittorrent_connection(current_values=current_values) + + assert result == {"success": False, "message": "Connection failed: boom"} + + def test_transmission_settings_test_connection_disables_verify_during_constructor(monkeypatch): """Settings callback should disable verify before transmission-rpc constructor bootstraps.""" from shelfmark.core.config import config as config_obj @@ -88,6 +122,36 @@ def test_transmission_settings_test_connection_disables_verify_during_constructo assert result["success"] is True +def test_transmission_settings_test_connection_returns_failure_for_transmission_error(monkeypatch): + """Transmission settings callback should convert TransmissionError into a failure payload.""" + from shelfmark.core.config import config as config_obj + from shelfmark.download.clients import settings as settings_module + + current_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + } + monkeypatch.setattr(config_obj, "get", make_config_getter(current_values)) + monkeypatch.setattr(settings_module, "get_ssl_verify", lambda _url: True) + + fake_transmission_rpc = types.ModuleType("transmission_rpc") + + class FakeTransmissionError(RuntimeError): + pass + + def fake_client_ctor(**_kwargs): + raise FakeTransmissionError("boom") + + fake_transmission_rpc.Client = fake_client_ctor + fake_transmission_rpc.TransmissionError = FakeTransmissionError + + with patch.dict("sys.modules", {"transmission_rpc": fake_transmission_rpc}): + result = settings_module._test_transmission_connection(current_values=current_values) + + assert result == {"success": False, "message": "Connection failed: boom"} + + def test_rtorrent_settings_test_connection_uses_unverified_transport_when_disabled(monkeypatch): """rTorrent settings callback should pass SafeTransport for HTTPS when verify is disabled.""" from shelfmark.core.config import config as config_obj diff --git a/tests/prowlarr/test_clients.py b/tests/prowlarr/test_clients.py index dca8fe08..91c5247e 100644 --- a/tests/prowlarr/test_clients.py +++ b/tests/prowlarr/test_clients.py @@ -3,6 +3,7 @@ Tests for the download client infrastructure. """ import pytest +import requests from shelfmark.download.clients import ( DownloadStatus, @@ -12,6 +13,7 @@ from shelfmark.download.clients import ( get_client, list_configured_clients, get_all_clients, + with_retry, _CLIENTS, ) @@ -183,6 +185,41 @@ class TestDownloadStatus: assert status.message == "Downloading at 1 MB/s" +class TestWithRetry: + """Tests for the retry decorator.""" + + def test_logs_single_retry_message_per_failed_attempt(self, monkeypatch): + import shelfmark.download.clients as clients_module + + debug_calls = [] + + monkeypatch.setattr(clients_module.random, "uniform", lambda _a, _b: 0.0) + monkeypatch.setattr(clients_module.time, "sleep", lambda _delay: None) + monkeypatch.setattr( + clients_module._logger, + "debug", + lambda *args, **kwargs: debug_calls.append((args, kwargs)), + ) + + attempts = {"count": 0} + + @with_retry(max_attempts=2, base_delay=1.0, max_delay=10.0, jitter=0.0) + def flaky_call(): + attempts["count"] += 1 + raise requests.exceptions.ConnectionError("boom") + + with pytest.raises(requests.exceptions.ConnectionError, match="boom"): + flaky_call() + + assert attempts["count"] == 2 + assert len(debug_calls) == 1 + args, kwargs = debug_calls[0] + assert kwargs == {} + assert args[:5] == ("Retry %s/%s for %s after %.1fs (error: %s)", 1, 2, "flaky_call", 1.0) + assert isinstance(args[5], requests.exceptions.ConnectionError) + assert str(args[5]) == "boom" + + class TestClientRegistry: """Tests for the client registry functions.""" diff --git a/tests/prowlarr/test_failure_scenarios.py b/tests/prowlarr/test_failure_scenarios.py index 533115af..b8b3d3d5 100644 --- a/tests/prowlarr/test_failure_scenarios.py +++ b/tests/prowlarr/test_failure_scenarios.py @@ -4,7 +4,7 @@ Failure scenario tests for Prowlarr handler and download clients. These tests verify error handling behavior - what happens when things go wrong. They use real clients where possible, with injected failures for edge cases. -Run with: docker exec test-cwabd python3 -m pytest /app/tests/prowlarr/test_failure_scenarios.py -v +Run with: uv run pytest tests/prowlarr/test_failure_scenarios.py -v """ import time diff --git a/tests/prowlarr/test_integration_clients.py b/tests/prowlarr/test_integration_clients.py index 80cd7ff0..3035d929 100644 --- a/tests/prowlarr/test_integration_clients.py +++ b/tests/prowlarr/test_integration_clients.py @@ -4,7 +4,7 @@ Integration tests for download clients. These tests require the Docker test stack to be running: docker compose -f docker-compose.test-clients.yml up -d -Run with: docker exec test-cwabd python3 -m pytest /app/tests/prowlarr/test_integration_clients.py -v -m integration +Run with: docker compose -f docker-compose.test-clients.yml exec shelfmark uv run pytest /app/tests/prowlarr/test_integration_clients.py -v -m integration These tests use the actual Docker stack configuration. Before running: 1. Start the test stack: docker compose -f docker-compose.test-clients.yml up -d diff --git a/tests/prowlarr/test_integration_failures.py b/tests/prowlarr/test_integration_failures.py index 8542937e..d6b15cbc 100644 --- a/tests/prowlarr/test_integration_failures.py +++ b/tests/prowlarr/test_integration_failures.py @@ -4,7 +4,7 @@ Integration failure tests for download clients. These tests verify error handling behavior against REAL running clients. They require the Docker test stack to be running. -Run with: docker exec test-cwabd python3 -m pytest /app/tests/prowlarr/test_integration_failures.py -v -m integration +Run with: docker compose -f docker-compose.test-clients.yml exec shelfmark uv run pytest /app/tests/prowlarr/test_integration_failures.py -v -m integration Key scenarios tested: - Invalid magnet links / bad torrents diff --git a/tests/prowlarr/test_integration_handler.py b/tests/prowlarr/test_integration_handler.py index 6b1c7188..6ffe34dc 100644 --- a/tests/prowlarr/test_integration_handler.py +++ b/tests/prowlarr/test_integration_handler.py @@ -3,7 +3,7 @@ Integration tests for the Prowlarr download handler. These tests verify the end-to-end download flow through the Prowlarr handler. -Run with: docker exec test-cwabd python3 -m pytest /app/tests/prowlarr/test_integration_handler.py -v -m integration +Run with: docker compose -f docker-compose.test-clients.yml exec shelfmark uv run pytest /app/tests/prowlarr/test_integration_handler.py -v -m integration """ import time diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index 62ce3f5d..92c05eb1 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -176,7 +176,9 @@ class TestSanitizeDownloadUrl: def test_sanitizes_multiple_query_params(self): """Sanitize all query pairs while keeping params.""" url = "http://prowlarr:9696/5/download?apikey = 12345&indexer = 7" - assert sanitize_download_url(url) == "http://prowlarr:9696/5/download?apikey=12345&indexer=7" + assert ( + sanitize_download_url(url) == "http://prowlarr:9696/5/download?apikey=12345&indexer=7" + ) def test_leaves_non_http_urls_untouched(self): """Do not modify magnet or other non-http URLs.""" @@ -188,6 +190,7 @@ class TestSanitizeDownloadUrl: url = "https://prowlarr:9696/5/download?apikey=12345" assert sanitize_download_url(url) == url + class TestDetectContentType: """Tests for the _detect_content_type_from_categories function.""" @@ -227,7 +230,16 @@ class FakeTorznabClient: } ] - def torznab_search(self, *, indexer_id: int, query: str, categories=None, search_type="book", limit=100, offset=0): + def torznab_search( + self, + *, + indexer_id: int, + query: str, + categories=None, + search_type="book", + limit=100, + offset=0, + ): del indexer_id, search_type, limit, offset self.calls.append((query, categories)) self.queries.append(query) @@ -369,3 +381,47 @@ class TestProwlarrLocalizedQueries: assert "The Final Empire" in fake_client.queries assert "A végső birodalom" in fake_client.queries assert "Mistborn: The Final Empire" not in fake_client.queries + + def test_auto_expand_logs_query_argument(self, monkeypatch): + import shelfmark.release_sources.prowlarr.source as prowlarr_source + + def fake_get(key: str, default=None): + values = { + "PROWLARR_INDEXERS": "", + "PROWLARR_AUTO_EXPAND": True, + } + return values.get(key, default) + + info_calls: list[tuple[str, tuple[object, ...]]] = [] + + monkeypatch.setattr(prowlarr_source.config, "get", fake_get) + monkeypatch.setattr( + prowlarr_source.logger, + "info", + lambda message, *args: info_calls.append((str(message), args)), + ) + + fake_client = FakeTorznabClient() + source = ProwlarrSource() + monkeypatch.setattr(source, "_get_client", lambda: fake_client) + + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="Anything", + authors=["Someone"], + ) + + from shelfmark.core.search_plan import build_release_search_plan + + plan = build_release_search_plan(book, languages=["en"]) + source.search(book, plan, content_type="ebook") + + query = fake_client.calls[0][0] + assert fake_client.calls == [(query, [7000]), (query, None)] + assert info_calls == [ + ( + "Prowlarr: no results for query '%s' with category filter, auto-expanding search", + (query,), + ) + ] diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..d2f14041 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1559 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "apprise" +version = "1.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "click" }, + { name = "markdown" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/f4/be5c7e39b83a2285ab62ae7c19bb10704836f59c0a5b4c471730f54c9f98/apprise-1.9.9.tar.gz", hash = "sha256:fd622c0df16bdc79ed385539735573488cafe2405d25747e87eebd6b09b26012", size = 2032822, upload-time = "2026-03-21T17:49:14.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/2f/54d068d7e011a8b4e0aae3e93b09a30b33bcf780829fe70c6e8876aeb0e0/apprise-1.9.9-py3-none-any.whl", hash = "sha256:55ceb8827a1c783d683881c9f77fa42eb43b3fc91b854419c452d557101c7068", size = 1519940, upload-time = "2026-03-21T17:49:11.847Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "behave" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "parse-type" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/4b/d0a8c23b6c8985e5544ea96d27105a273ea22051317f850c2cdbf2029fe4/behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", size = 701696, upload-time = "2018-02-25T20:06:38.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/6c/ec9169548b6c4cb877aaa6773408ca08ae2a282805b958dbc163cb19822d/behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c", size = 136779, upload-time = "2018-02-25T20:06:34.436Z" }, +] + +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +] + +[[package]] +name = "cssselect" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/2e/cdfd8b01c37cbf4f9482eefd455853a3cf9c995029a46acd31dfaa9c1dd6/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92", size = 40589, upload-time = "2026-01-29T07:00:26.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "emoji" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "fasteners" +version = "0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/18/7881a99ba5244bfc82f06017316ffe93217dbbbcfa52b887caa1d4f2a6d3/fasteners-0.20.tar.gz", hash = "sha256:55dce8792a41b56f727ba6e123fcaee77fd87e638a6863cec00007bfea84c8d8", size = 25087, upload-time = "2025-08-11T10:19:37.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ac/e5d886f892666d2d1e5cb8c1a41146e1d79ae8896477b1153a21711d3b44/fasteners-0.20-py3-none-any.whl", hash = "sha256:9422c40d1e350e4259f509fb2e608d6bc43c0136f79a00db1b49046029d0b3b7", size = 18702, upload-time = "2025-08-11T10:19:35.716Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-cors" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" }, +] + +[[package]] +name = "flask-socketio" +version = "5.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "flask" }, + { name = "jinja2" }, + { name = "python-socketio" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/61/3287c8a8fe4c3c59f2573d71aea7d334a113383ed3e6eb96e290dc80115f/flask_socketio-5.6.1.tar.gz", hash = "sha256:fe5bd995c3ed4da9a98f335d0d830fa1a19d84a64789f6265642a671fdacaeac", size = 37857, upload-time = "2026-02-21T13:07:52.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/98/2a46f4a3117c17fd36e07ad8b085054451e96723baaeea245682156ba546/flask_socketio-5.6.1-py3-none-any.whl", hash = "sha256:51a3f71b28b4476c650829607e3a993e076034db6c3cc31f718f0a4b45939d42", size = 18683, upload-time = "2026-02-21T13:07:51.442Z" }, +] + +[[package]] +name = "gevent" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/27/1062fa31333dc3428a1f5f33cd6598b0552165ba679ca3ba116de42c9e8e/gevent-26.4.0.tar.gz", hash = "sha256:288d03addfccf0d1c67268358b6759b04392bf3bc35d26f3d9a45c82899c292d", size = 6242440, upload-time = "2026-04-09T12:08:19.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f3/64638a941988f09aa1816e2674eb1efb215b6fa64a97edef6e25177b0845/gevent-26.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7ab0f183a6fd2369eef619832eef14f1f2f69c605163c3f2dc41deb799af4a71", size = 2967206, upload-time = "2026-04-08T21:25:44.73Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/a86be65a51d3ebb92c82a70adc9c5c32b1a9d9579120d0be1db7cf534ce0/gevent-26.4.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7e5906860e632bf965e1966c57e6bfc19dcb79dc262f04fdb0a9d7c12147bf69", size = 1792916, upload-time = "2026-04-08T22:23:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/40/92/18fdb4b28f20129395f1c041773adee99e7fc2bcfff216df93bfb80787d5/gevent-26.4.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:297a361071dc6708115d4544859321e93b02a6cd5823ba02c0a909530a519d45", size = 1886617, upload-time = "2026-04-08T22:27:13.716Z" }, + { url = "https://files.pythonhosted.org/packages/af/c9/d02222ecf79d10c8a0c2755661485395b58c4bfffaafd88bcc230ce392de/gevent-26.4.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:7e74f59e5c9011afa2a9cb7106bb9a59f2a1f74c3d7b272c1b852eb0bc0b8f90", size = 1837660, upload-time = "2026-04-08T22:34:40.823Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/9376d125fa4f7b0f269925d0d622eda0ff8f8dfc8d0c097a096c511fc738/gevent-26.4.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:45d6010a6a981f5a2b3411c4e38fbe305a1b46e4b12db3b4914775927dea7ba4", size = 2119342, upload-time = "2026-04-08T21:54:17.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/1fe2817daca8e97c365fd739dd4057f71cce26ef600fb8465deb8060c83c/gevent-26.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dc38137ba2f43794c488615aafa2eefd0cc142f484a8274d4c827ed7a031a1e2", size = 1805672, upload-time = "2026-04-08T22:26:55.792Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cc/ccbcbd56e7e85482291fbb90a317f5febf630ec4174a91506f4167ba0912/gevent-26.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:29a225d2d4da37e20c7a246754a64442d0e43e4534b8cc764f89530bb22a4237", size = 2145594, upload-time = "2026-04-08T22:00:05.275Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b9/7dd37b6001d16f692b1bfb6e68cad642beb38b34a753c29bbff312f46e4b/gevent-26.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:d1c08bc9bb6bd79732a26710a99588b5e9b67b668e165dd609704b876f41baab", size = 1703189, upload-time = "2026-04-08T22:48:31.713Z" }, +] + +[[package]] +name = "gevent-websocket" +version = "0.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gevent" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/d2/6fa19239ff1ab072af40ebf339acd91fb97f34617c2ee625b8e34bf42393/gevent-websocket-0.10.1.tar.gz", hash = "sha256:7eaef32968290c9121f7c35b973e2cc302ffb076d018c9068d2f5ca8b2d85fb0", size = 18366, upload-time = "2017-03-12T22:46:05.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/84/2dc373eb6493e00c884cc11e6c059ec97abae2678d42f06bf780570b0193/gevent_websocket-0.10.1-py3-none-any.whl", hash = "sha256:17b67d91282f8f4c973eba0551183fc84f56f1c90c8f6b6b30256f31f66f5242", size = 22987, upload-time = "2017-03-12T22:46:03.611Z" }, +] + +[[package]] +name = "greenlet" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, +] + +[[package]] +name = "gunicorn" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mouseinfo" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyperclip" }, + { name = "python3-xlib", marker = "sys_platform == 'linux'" }, + { name = "rubicon-objc", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/fa/b2ba8229b9381e8f6381c1dcae6f4159a7f72349e414ed19cfbbd1817173/MouseInfo-0.1.3.tar.gz", hash = "sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7", size = 10850, upload-time = "2020-03-27T21:20:10.136Z" } + +[[package]] +name = "mycdp" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/cf/8990229f1e51a1531105503254e3f76d6acd77e783ad9f9236c95a1a59d7/mycdp-1.3.7.tar.gz", hash = "sha256:0861fd85949eafd1ec794ebe58b54515c571544c983affbbc74249119ff94d21", size = 224859, upload-time = "2026-03-19T21:22:29.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/0e/83918568b03bc2c7326eaf66454c1cb3d8734d34ccb1c4a21454ed9b8230/mycdp-1.3.7-py3-none-any.whl", hash = "sha256:49aa0f8cfbc608b157b3ad35999a74e32cddc674d7f0e0bbe402cdcef7ce69d6", size = 252208, upload-time = "2026-03-19T21:22:28.372Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "parameterized" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/49/00c0c0cc24ff4266025a53e41336b79adaa5a4ebfad214f433d623f9865e/parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1", size = 24351, upload-time = "2023-03-27T02:01:11.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2f/804f58f0b856ab3bf21617cccf5b39206e6c4c94c2cd227bde125ea6105f/parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b", size = 20475, upload-time = "2023-03-27T02:01:09.31Z" }, +] + +[[package]] +name = "parse" +version = "1.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/18/0bea374e5ec3c8ba15365570002187f3fef9d7265ffbc2f649529878cc80/parse-1.21.1.tar.gz", hash = "sha256:825e1a88e9d9fb481b8d2ca709c6195558b6eaa97c559ad3a9a20aa2d12815a3", size = 29105, upload-time = "2026-02-19T02:20:07.645Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/13/114daf766c33aec6c5a3954e7ea653f8a7ade9602c5c5a2228281698c490/parse-1.21.1-py2.py3-none-any.whl", hash = "sha256:55339ca698019815df3b8e8b550e5933933527e623b0cdf1ca2f404da35ffb47", size = 19693, upload-time = "2026-02-19T02:20:06.575Z" }, +] + +[[package]] +name = "parse-type" +version = "0.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" }, +] + +[[package]] +name = "pdbp" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pygments" }, + { name = "tabcompleter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/91/2d614b0db12840d646159f65510415ade0db9db595d6dee3eac60dfe9302/pdbp-1.8.2.tar.gz", hash = "sha256:367c25c17555d3ac1f024b9ad494ff50e6e20f6494a84741487f3e6596d88f94", size = 25843, upload-time = "2026-01-14T03:10:28.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/fe/53ac0cd932db5dcaf55961bc7cb7afdca8d80d8cc7406ed661f0c7dc111a/pdbp-1.8.2-py3-none-any.whl", hash = "sha256:d4fd05e177636b5ccd0b2e03e378cec57afc06149e5fd975de6f8ddb3d0109a8", size = 21969, upload-time = "2026-01-14T03:10:27.062Z" }, +] + +[[package]] +name = "pip" +version = "26.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pyautogui" +version = "0.9.54" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mouseinfo" }, + { name = "pygetwindow" }, + { name = "pymsgbox" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "pyscreeze" }, + { name = "python3-xlib", marker = "sys_platform == 'linux'" }, + { name = "pytweening" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/ff/cdae0a8c2118a0de74b6cf4cbcdcaf8fd25857e6c3f205ce4b1794b27814/PyAutoGUI-0.9.54.tar.gz", hash = "sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2", size = 61236, upload-time = "2023-05-24T20:11:32.972Z" } + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygetwindow" +version = "0.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyrect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/70/c7a4f46dbf06048c6d57d9489b8e0f9c4c3d36b7479f03c5ca97eaa2541d/PyGetWindow-0.0.9.tar.gz", hash = "sha256:17894355e7d2b305cd832d717708384017c1698a90ce24f6f7fbf0242dd0a688", size = 9699, upload-time = "2020-10-04T02:12:50.806Z" } + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymsgbox" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/6a/e80da7594ee598a776972d09e2813df2b06b3bc29218f440631dfa7c78a8/pymsgbox-2.0.1.tar.gz", hash = "sha256:98d055c49a511dcc10fa08c3043e7102d468f5e4b3a83c6d3c61df722c7d798d", size = 20768, upload-time = "2025-09-09T00:38:56.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/3e/08c8cac81b2b2f7502746e6b9c8e5b0ec6432cd882c605560fc409aaf087/pymsgbox-2.0.1-py3-none-any.whl", hash = "sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b", size = 9994, upload-time = "2025-09-09T00:38:55.672Z" }, +] + +[[package]] +name = "pynose" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/ad/1b5d064d53a3cc33999649c5f3b0e7cee739e7404a30bb7efd1443604a7c/pynose-1.5.5.tar.gz", hash = "sha256:81da4e26473f98dd37497248eef4352d3221d1d56edf874a00c6bdda6daf7f49", size = 123419, upload-time = "2025-09-08T20:45:15.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/02/70dd00c947bcb4a1e9f883761aa4632a9d61b5bf5f5ad4fae5e8cb06200c/pynose-1.5.5-py3-none-any.whl", hash = "sha256:673751d53fcfc79b1e48c14f36c7a24779ad43676eeb85736934de6a2b3d8ec8", size = 130706, upload-time = "2025-09-08T20:45:13.697Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" }, +] + +[[package]] +name = "pyotp" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/b2/1d5994ba2acde054a443bd5e2d384175449c7d2b6d1a0614dbca3a63abfc/pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63", size = 17763, upload-time = "2023-07-27T23:41:03.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376, upload-time = "2023-07-27T23:41:01.685Z" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +] + +[[package]] +name = "pyrect" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/04/2ba023d5f771b645f7be0c281cdacdcd939fe13d1deb331fc5ed1a6b3a98/PyRect-0.2.0.tar.gz", hash = "sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78", size = 17219, upload-time = "2022-03-16T04:45:52.36Z" } + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pyscreeze" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/f0/cb456ac4f1a73723d5b866933b7986f02bacea27516629c00f8e7da94c2d/pyscreeze-1.0.1.tar.gz", hash = "sha256:cf1662710f1b46aa5ff229ee23f367da9e20af4a78e6e365bee973cad0ead4be", size = 27826, upload-time = "2024-08-20T23:03:07.291Z" } + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-html" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pytest" }, + { name = "pytest-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/35/24c115767a0d1dacc9bc68255601d833d19531bc3bfc595f77ad2b0db2fc/pytest_html-4.0.2.tar.gz", hash = "sha256:88682b9e8e51392472546a70a2139b27d6bc1834a4afd3e41da33c9d9f91e4a4", size = 149084, upload-time = "2023-09-14T07:07:28.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/44/01d23e607f6c4e452ed978bd0a5677883d98ab156d5379b2ec7cb4c63c7a/pytest_html-4.0.2-py3-none-any.whl", hash = "sha256:907c3e68462df129d3ee96dee58bd63f70216b06421836b22fd3fd57ef314acb", size = 23110, upload-time = "2023-09-14T07:07:27.177Z" }, +] + +[[package]] +name = "pytest-metadata" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/85/8c969f8bec4e559f8f2b958a15229a35495f5b4ce499f6b865eac54b878d/pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8", size = 9952, upload-time = "2024-02-12T19:38:44.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" }, +] + +[[package]] +name = "pytest-ordering" +version = "0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/ba/65091c36e6e18da479d22d860586e3ba3a4237cc92a66e3ddd945e4fe761/pytest-ordering-0.6.tar.gz", hash = "sha256:561ad653626bb171da78e682f6d39ac33bb13b3e272d406cd555adb6b006bda6", size = 2629, upload-time = "2018-11-14T00:55:26.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/98/adc368fe369465f291ab24e18b9900473786ed1afdf861ba90467eb0767e/pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6", size = 4643, upload-time = "2018-10-25T16:25:18.445Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload-time = "2025-10-10T07:06:01.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload-time = "2025-10-10T07:06:00.019Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-engineio" +version = "4.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" }, +] + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "python3-xlib" +version = "0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/c6/2c5999de3bb1533521f1101e8fe56fd9c266732f4d48011c7c69b29d12ae/python3-xlib-0.15.tar.gz", hash = "sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8", size = 132828, upload-time = "2014-05-31T12:28:59.603Z" } + +[[package]] +name = "pytweening" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/0c/c16bc93ac2755bac0066a8ecbd2a2931a1735a6fffd99a2b9681c7e83e90/pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b", size = 171241, upload-time = "2024-02-20T03:37:56.809Z" } + +[[package]] +name = "pyvirtualdisplay" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/9f/23e5a82987c26d225139948a224a93318d7a7c8b166d4dbe4de7426dc4e4/PyVirtualDisplay-3.0.tar.gz", hash = "sha256:09755bc3ceb6eb725fb07eca5425f43f2358d3bf08e00d2a9b792a1aedd16159", size = 18560, upload-time = "2022-02-13T07:57:05.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/eb/c3b8deb661cb3846db63288c99bbb39f217b7807fc8acb2fd058db41e2e6/PyVirtualDisplay-3.0-py3-none-any.whl", hash = "sha256:40d4b8dfe4b8de8552e28eb367647f311f88a130bf837fe910e7f180d5477f0e", size = 15258, upload-time = "2022-02-13T07:57:04.051Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "qbittorrent-api" +version = "2025.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/b4/506b0296fd17d90cb2185149b4fa576645c6fd26397684bfb0806a35435b/qbittorrent_api-2025.11.1.tar.gz", hash = "sha256:5cb2a469f8bc95f616e03bd0259d8b4be60a5c3df0d49910c44309b0f59724c4", size = 1323104, upload-time = "2025-11-20T07:34:50.263Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/c5/b77517fb2179ec640034ed8850fee9f9dcff457daef7aeebaf6463cf962d/qbittorrent_api-2025.11.1-py3-none-any.whl", hash = "sha256:6e60f1daa25232d48753d0e6860b0684092910b3fe16fbe449be7fa31caa687a", size = 67042, upload-time = "2025-11-20T07:34:48.684Z" }, +] + +[[package]] +name = "rarfile" +version = "4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/3f/3118a797444e7e30e784921c4bfafb6500fb288a0c84cb8c32ed15853c16/rarfile-4.2.tar.gz", hash = "sha256:8e1c8e72d0845ad2b32a47ab11a719bc2e41165ec101fd4d3fe9e92aa3f469ef", size = 153476, upload-time = "2024-04-03T17:10:53.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/fc/ab37559419ca36dd8dd317c3a98395ed4dcee2beeb28bf6059b972906727/rarfile-4.2-py3-none-any.whl", hash = "sha256:8757e1e3757e32962e229cab2432efc1f15f210823cc96ccba0f6a39d17370c9", size = 29052, upload-time = "2024-04-03T17:10:52.632Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rubicon-objc" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/d2/d39ecd205661a5c14c90dbd92a722a203848a3621785c9783716341de427/rubicon_objc-0.5.3.tar.gz", hash = "sha256:74c25920c5951a05db9d3a1aac31d23816ec7dacc841a5b124d911b99ea71b9a", size = 171512, upload-time = "2025-12-03T03:51:10.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ab/e834c01138c272fb2e37d2f3c7cba708bc694dbc7b3f03b743f29ceb92d5/rubicon_objc-0.5.3-py3-none-any.whl", hash = "sha256:31dedcda9be38435f5ec067906e1eea5d0ddb790330e98a22e94ff424758b415", size = 64414, upload-time = "2025-12-03T03:51:09.082Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + +[[package]] +name = "sbvirtualdisplay" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/23/3c5b816b0c4de8452a8ceef5be39b4cdba05d8a15c214673c7cf4f4b499a/sbvirtualdisplay-1.4.0.tar.gz", hash = "sha256:29a365b509cd7bfde4f758603b7b75703909b11cdf4245abc8f828ed35660d9b", size = 12631, upload-time = "2024-12-16T15:02:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/65/832a5d0953f03c0b9bc267c58813aa51957b27c0902743f8e4d11d020dd1/sbvirtualdisplay-1.4.0-py3-none-any.whl", hash = "sha256:516de155219aa342c4e090a3c5126cfe6b12416334bcba3255268e44a5e8a206", size = 12490, upload-time = "2024-12-16T15:02:17.195Z" }, +] + +[[package]] +name = "selenium" +version = "4.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/7c/133d00d6d013a17d3f39199f27f1a780ec2e95d7b9aa997dc1b8ac2e62a7/selenium-4.41.0.tar.gz", hash = "sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa", size = 937872, upload-time = "2026-02-20T03:42:06.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/d6/e4160989ef6b272779af6f3e5c43c3ba9be6687bdc21c68c3fb220e555b3/selenium-4.41.0-py3-none-any.whl", hash = "sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1", size = 9532858, upload-time = "2026-02-20T03:42:03.218Z" }, +] + +[[package]] +name = "seleniumbase" +version = "4.47.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "beautifulsoup4" }, + { name = "behave" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "colorama" }, + { name = "cssselect" }, + { name = "exceptiongroup" }, + { name = "execnet" }, + { name = "fasteners" }, + { name = "filelock" }, + { name = "h11" }, + { name = "idna" }, + { name = "iniconfig" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "markupsafe" }, + { name = "mdurl" }, + { name = "mycdp" }, + { name = "nest-asyncio" }, + { name = "outcome" }, + { name = "packaging" }, + { name = "parameterized" }, + { name = "parse" }, + { name = "parse-type" }, + { name = "pdbp" }, + { name = "pip" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyautogui", marker = "sys_platform == 'linux'" }, + { name = "pygments" }, + { name = "pynose" }, + { name = "pyotp" }, + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "pytest" }, + { name = "pytest-html" }, + { name = "pytest-metadata" }, + { name = "pytest-ordering" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-xdist" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "sbvirtualdisplay" }, + { name = "selenium" }, + { name = "setuptools" }, + { name = "six" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, + { name = "soupsieve" }, + { name = "tabcompleter" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "websocket-client" }, + { name = "websockets" }, + { name = "wheel" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/88/fc6f7aaf038ae6209b9af14678900b57cefead65ad3e11279fe727b929ee/seleniumbase-4.47.9.tar.gz", hash = "sha256:12c147c9d055c624d5f418caebf16e918ef89a237cb0aae54834c70619c1730b", size = 654045, upload-time = "2026-04-01T05:10:16.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/b9/9150db5e4f2fdb2a04137ba1fd5a1ce8f26fefd2be738d39c7c61e82df42/seleniumbase-4.47.9-py3-none-any.whl", hash = "sha256:7abbe60d598f67bab2efefd24e3ac3caa0f0cb2b34cfc67a19ddd11bc31af77b", size = 660814, upload-time = "2026-04-01T05:10:12.578Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shelfmark" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "apprise" }, + { name = "authlib" }, + { name = "beautifulsoup4" }, + { name = "defusedxml" }, + { name = "dnspython" }, + { name = "emoji" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-socketio" }, + { name = "gevent" }, + { name = "gevent-websocket" }, + { name = "gunicorn" }, + { name = "psutil" }, + { name = "python-socketio" }, + { name = "qbittorrent-api" }, + { name = "rarfile" }, + { name = "requests", extra = ["socks"] }, + { name = "tqdm" }, + { name = "transmission-rpc" }, +] + +[package.optional-dependencies] +browser = [ + { name = "pyautogui" }, + { name = "python-xlib" }, + { name = "pyvirtualdisplay" }, + { name = "seleniumbase" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-xdist" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "apprise", specifier = ">=1.9.0" }, + { name = "authlib", specifier = ">=1.6.6,<1.7" }, + { name = "beautifulsoup4" }, + { name = "defusedxml" }, + { name = "dnspython" }, + { name = "emoji" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-socketio" }, + { name = "gevent" }, + { name = "gevent-websocket" }, + { name = "gunicorn" }, + { name = "psutil" }, + { name = "pyautogui", marker = "extra == 'browser'" }, + { name = "python-socketio" }, + { name = "python-xlib", marker = "extra == 'browser'" }, + { name = "pyvirtualdisplay", marker = "extra == 'browser'" }, + { name = "qbittorrent-api" }, + { name = "rarfile" }, + { name = "requests", extras = ["socks"] }, + { name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.47.9" }, + { name = "tqdm" }, + { name = "transmission-rpc" }, +] +provides-extras = ["browser"] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.408" }, + { name = "pytest" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "ruff", specifier = "==0.15.9" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "tabcompleter" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/1a/ed3544579628c5709bae6fae2255e94c6982a9ff77d42d8ba59fd2f3b21a/tabcompleter-1.4.0.tar.gz", hash = "sha256:7562a9938e62f8e7c3be612c3ac4e14c5ec4307b58ba9031c148260e866e8814", size = 10431, upload-time = "2024-10-28T00:44:52.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/44/bb509c3d2c0b5a87e7a5af1d5917a402a32ff026f777a6d7cb6990746cbb/tabcompleter-1.4.0-py3-none-any.whl", hash = "sha256:d744aa735b49c0a6cc2fb8fcd40077fec47425e4388301010b14e6ce3311368b", size = 6725, upload-time = "2024-10-28T00:44:51.267Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "transmission-rpc" +version = "7.0.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/b8/dc4debf525c3bb8a676f4fd0ab8534845e3b067c78a81ad05ac39014d849/transmission_rpc-7.0.11.tar.gz", hash = "sha256:5872322e60b42e368bc9c4724773aea4593113cb19bd2da589f0ffcdabe57963", size = 113744, upload-time = "2024-08-20T22:41:07.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4c/6319bcb1026e3f78c9cbcc9c24de77a76f09954e67ffc5ebfc29f7ce4b90/transmission_rpc-7.0.11-py3-none-any.whl", hash = "sha256:94fd008b54640dd9fff14d7ae26848f901e9d130a70950b8930f9b395988914f", size = 28231, upload-time = "2024-08-20T22:41:05.777Z" }, +] + +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wheel" +version = "0.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "zope-event" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/da/3c89de3917751446728b8898b4d53318bc2f8f6bf8196e150a063c59905e/zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb", size = 209223, upload-time = "2026-01-09T08:05:36.449Z" }, + { url = "https://files.pythonhosted.org/packages/00/7f/62d00ec53f0a6e5df0c984781e6f3999ed265129c4c3413df8128d1e0207/zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf", size = 209366, upload-time = "2026-01-09T08:05:38.197Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/f241986315174be8e00aabecfc2153cf8029c1327cab8ed53a9d979d7e08/zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080", size = 261037, upload-time = "2026-01-09T08:05:39.568Z" }, + { url = "https://files.pythonhosted.org/packages/02/cc/b321c51d6936ede296a1b8860cf173bee2928357fe1fff7f97234899173f/zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c", size = 264219, upload-time = "2026-01-09T08:05:41.624Z" }, + { url = "https://files.pythonhosted.org/packages/ab/fb/5f5e7b40a2f4efd873fe173624795ca47eaa22e29051270c981361b45209/zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c", size = 264390, upload-time = "2026-01-09T08:05:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/f9/82/3f2bc594370bc3abd58e5f9085d263bf682a222f059ed46275cde0570810/zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48", size = 212585, upload-time = "2026-01-09T08:05:44.419Z" }, +]