mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
160 lines
5.4 KiB
Python
160 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
import atexit
|
|
import threading
|
|
from contextlib import contextmanager
|
|
from functools import cached_property
|
|
from http import HTTPStatus
|
|
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeVar
|
|
|
|
import requests
|
|
|
|
from beets import __version__
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
|
|
class BeetsHTTPError(requests.exceptions.HTTPError):
|
|
STATUS: ClassVar[HTTPStatus]
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
super().__init__(
|
|
f"HTTP Error: {self.STATUS.value} {self.STATUS.phrase}",
|
|
*args,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
class HTTPNotFoundError(BeetsHTTPError):
|
|
STATUS = HTTPStatus.NOT_FOUND
|
|
|
|
|
|
class Closeable(Protocol):
|
|
"""Protocol for objects that have a close method."""
|
|
|
|
def close(self) -> None: ...
|
|
|
|
|
|
C = TypeVar("C", bound=Closeable)
|
|
|
|
|
|
class SingletonMeta(type, Generic[C]):
|
|
"""Metaclass ensuring a single shared instance per class.
|
|
|
|
Creates one instance per class type on first instantiation, reusing it
|
|
for all subsequent calls. Automatically registers cleanup on program exit
|
|
for proper resource management.
|
|
"""
|
|
|
|
_instances: ClassVar[dict[type[Any], Any]] = {}
|
|
_lock: ClassVar[threading.Lock] = threading.Lock()
|
|
|
|
def __call__(cls, *args: Any, **kwargs: Any) -> C:
|
|
if cls not in cls._instances:
|
|
with cls._lock:
|
|
if cls not in SingletonMeta._instances:
|
|
instance = super().__call__(*args, **kwargs)
|
|
SingletonMeta._instances[cls] = instance
|
|
atexit.register(instance.close)
|
|
return SingletonMeta._instances[cls]
|
|
|
|
|
|
class TimeoutSession(requests.Session, metaclass=SingletonMeta):
|
|
"""HTTP session with automatic timeout and status checking.
|
|
|
|
Extends requests.Session to provide sensible defaults for beets HTTP
|
|
requests: automatic timeout enforcement, status code validation, and
|
|
proper user agent identification.
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/"
|
|
|
|
def request(self, *args, **kwargs):
|
|
"""Execute HTTP request with automatic timeout and status validation.
|
|
|
|
Ensures all requests have a timeout (defaults to 10 seconds) and raises
|
|
an exception for HTTP error status codes.
|
|
"""
|
|
kwargs.setdefault("timeout", 10)
|
|
r = super().request(*args, **kwargs)
|
|
r.raise_for_status()
|
|
|
|
return r
|
|
|
|
|
|
class RequestHandler:
|
|
"""Manages HTTP requests with custom error handling and session management.
|
|
|
|
Provides a reusable interface for making HTTP requests with automatic
|
|
conversion of standard HTTP errors to beets-specific exceptions. Supports
|
|
custom session types and error mappings that can be overridden by
|
|
subclasses.
|
|
|
|
Usage:
|
|
Subclass and override :class:`RequestHandler.session_type`,
|
|
:class:`RequestHandler.explicit_http_errors` or
|
|
:class:`RequestHandler.status_to_error()` to customize behavior.
|
|
|
|
Use :class:`RequestHandler.get()` or
|
|
:class:`RequestHandler.fetch_json()` for common operations, or
|
|
:class:`RequestHandler.request()` for full control over HTTP methods.
|
|
|
|
Feel free to define common methods that are used in multiple plugins.
|
|
"""
|
|
|
|
session_type: ClassVar[type[TimeoutSession]] = TimeoutSession
|
|
explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [
|
|
HTTPNotFoundError
|
|
]
|
|
|
|
@cached_property
|
|
def session(self) -> Any:
|
|
"""Lazily initialize and cache the HTTP session."""
|
|
return self.session_type()
|
|
|
|
def status_to_error(
|
|
self, code: int
|
|
) -> type[requests.exceptions.HTTPError] | None:
|
|
"""Map HTTP status codes to beets-specific exception types.
|
|
|
|
Searches the configured explicit HTTP errors for a matching status code.
|
|
Returns None if no specific error type is registered for the given code.
|
|
"""
|
|
return next(
|
|
(e for e in self.explicit_http_errors if e.STATUS == code), None
|
|
)
|
|
|
|
@contextmanager
|
|
def handle_http_error(self) -> Iterator[None]:
|
|
"""Convert standard HTTP errors to beets-specific exceptions.
|
|
|
|
Wraps operations that may raise HTTPError, automatically translating
|
|
recognized status codes into their corresponding beets exception types.
|
|
Unrecognized errors are re-raised unchanged.
|
|
"""
|
|
try:
|
|
yield
|
|
except requests.exceptions.HTTPError as e:
|
|
if beets_error := self.status_to_error(e.response.status_code):
|
|
raise beets_error(response=e.response) from e
|
|
raise
|
|
|
|
def request(self, method: str, *args, **kwargs) -> requests.Response:
|
|
"""Perform HTTP request using the session with automatic error handling.
|
|
|
|
Delegates to the underlying session method while converting recognized
|
|
HTTP errors to beets-specific exceptions through the error handler.
|
|
"""
|
|
with self.handle_http_error():
|
|
return getattr(self.session, method)(*args, **kwargs)
|
|
|
|
def get(self, *args, **kwargs) -> requests.Response:
|
|
"""Perform HTTP GET request with automatic error handling."""
|
|
return self.request("get", *args, **kwargs)
|
|
|
|
def fetch_json(self, *args, **kwargs):
|
|
"""Fetch and parse JSON data from an HTTP endpoint."""
|
|
return self.get(*args, **kwargs).json()
|