Skip to content

Commit 3cfda4d

Browse files
committed
Refactor HTTP request handling with RequestHandler base class
Introduce a new RequestHandler base class to introduce a shared session, centralize HTTP request management and error handling across plugins. Key changes: - Add RequestHandler base class with a shared/cached session - Convert TimeoutSession to use SingletonMeta for proper resource management - Create LyricsRequestHandler subclass with lyrics-specific error handling - Update MusicBrainzAPI to inherit from RequestHandler
1 parent 86238bf commit 3cfda4d

File tree

4 files changed

+175
-46
lines changed

4 files changed

+175
-46
lines changed

beetsplug/_utils/requests.py

Lines changed: 127 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,149 @@
1+
from __future__ import annotations
2+
13
import atexit
4+
import threading
5+
from contextlib import contextmanager
6+
from functools import cached_property
27
from http import HTTPStatus
8+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeVar
39

410
import requests
511

612
from beets import __version__
713

14+
if TYPE_CHECKING:
15+
from collections.abc import Iterator
16+
17+
18+
class BeetsHTTPError(requests.exceptions.HTTPError):
19+
STATUS: ClassVar[HTTPStatus]
20+
21+
def __init__(self, *args, **kwargs) -> None:
22+
super().__init__(
23+
f"HTTP Error: {self.STATUS.value} {self.STATUS.phrase}",
24+
*args,
25+
**kwargs,
26+
)
27+
28+
29+
class HTTPNotFoundError(BeetsHTTPError):
30+
STATUS = HTTPStatus.NOT_FOUND
31+
32+
33+
class Closeable(Protocol):
34+
"""Protocol for objects that have a close method."""
35+
36+
def close(self) -> None: ...
37+
38+
39+
C = TypeVar("C", bound=Closeable)
40+
41+
42+
class SingletonMeta(type, Generic[C]):
43+
"""Metaclass ensuring a single shared instance per class.
44+
45+
Creates one instance per class type on first instantiation, reusing it
46+
for all subsequent calls. Automatically registers cleanup on program exit
47+
for proper resource management.
48+
"""
49+
50+
_instances: ClassVar[dict[type[Any], Any]] = {}
51+
_lock: ClassVar[threading.Lock] = threading.Lock()
852

9-
class HTTPNotFoundError(requests.exceptions.HTTPError):
10-
pass
53+
def __call__(cls, *args: Any, **kwargs: Any) -> C:
54+
if cls not in cls._instances:
55+
with cls._lock:
56+
if cls not in SingletonMeta._instances:
57+
instance = super().__call__(*args, **kwargs)
58+
SingletonMeta._instances[cls] = instance
59+
atexit.register(instance.close)
60+
return SingletonMeta._instances[cls]
1161

1262

13-
class CaptchaError(requests.exceptions.HTTPError):
14-
pass
63+
class TimeoutSession(requests.Session, metaclass=SingletonMeta):
64+
"""HTTP session with automatic timeout and status checking.
1565
66+
Extends requests.Session to provide sensible defaults for beets HTTP
67+
requests: automatic timeout enforcement, status code validation, and
68+
proper user agent identification.
69+
"""
1670

17-
class TimeoutSession(requests.Session):
1871
def __init__(self, *args, **kwargs) -> None:
1972
super().__init__(*args, **kwargs)
2073
self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/"
2174

22-
@atexit.register
23-
def close_session():
24-
"""Close the requests session on shut down."""
25-
self.close()
26-
2775
def request(self, *args, **kwargs):
28-
"""Wrap the request method to raise an exception on HTTP errors."""
76+
"""Execute HTTP request with automatic timeout and status validation.
77+
78+
Ensures all requests have a timeout (defaults to 10 seconds) and raises
79+
an exception for HTTP error status codes.
80+
"""
2981
kwargs.setdefault("timeout", 10)
3082
r = super().request(*args, **kwargs)
31-
if r.status_code == HTTPStatus.NOT_FOUND:
32-
raise HTTPNotFoundError("HTTP Error: Not Found", response=r)
33-
if 300 <= r.status_code < 400:
34-
raise CaptchaError("Captcha is required", response=r)
35-
3683
r.raise_for_status()
3784

3885
return r
86+
87+
88+
class RequestHandler:
89+
"""Manages HTTP requests with custom error handling and session management.
90+
91+
Provides a reusable interface for making HTTP requests with automatic
92+
conversion of standard HTTP errors to beets-specific exceptions. Supports
93+
custom session types and error mappings that can be overridden by
94+
subclasses.
95+
"""
96+
97+
session_type: ClassVar[type[TimeoutSession]] = TimeoutSession
98+
explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [
99+
HTTPNotFoundError
100+
]
101+
102+
@cached_property
103+
def session(self) -> Any:
104+
"""Lazily initialize and cache the HTTP session."""
105+
return self.session_type()
106+
107+
def status_to_error(
108+
self, code: int
109+
) -> type[requests.exceptions.HTTPError] | None:
110+
"""Map HTTP status codes to beets-specific exception types.
111+
112+
Searches the configured explicit HTTP errors for a matching status code.
113+
Returns None if no specific error type is registered for the given code.
114+
"""
115+
return next(
116+
(e for e in self.explicit_http_errors if e.STATUS == code), None
117+
)
118+
119+
@contextmanager
120+
def handle_http_error(self) -> Iterator[None]:
121+
"""Convert standard HTTP errors to beets-specific exceptions.
122+
123+
Wraps operations that may raise HTTPError, automatically translating
124+
recognized status codes into their corresponding beets exception types.
125+
Unrecognized errors are re-raised unchanged.
126+
"""
127+
try:
128+
yield
129+
except requests.exceptions.HTTPError as e:
130+
if beets_error := self.status_to_error(e.response.status_code):
131+
raise beets_error(response=e.response) from e
132+
raise
133+
134+
def request(self, method: str, *args, **kwargs) -> requests.Response:
135+
"""Perform HTTP request using the session with automatic error handling.
136+
137+
Delegates to the underlying session method while converting recognized
138+
HTTP errors to beets-specific exceptions through the error handler.
139+
"""
140+
with self.handle_http_error():
141+
return getattr(self.session, method)(*args, **kwargs)
142+
143+
def get(self, *args, **kwargs) -> requests.Response:
144+
"""Perform HTTP GET request with automatic error handling."""
145+
return self.request("get", *args, **kwargs)
146+
147+
def fetch_json(self, *args, **kwargs):
148+
"""Fetch and parse JSON data from an HTTP endpoint."""
149+
return self.get(*args, **kwargs).json()

beetsplug/lyrics.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,17 @@
3434
from bs4 import BeautifulSoup
3535
from unidecode import unidecode
3636

37-
import beets
3837
from beets import plugins, ui
3938
from beets.autotag.distance import string_dist
4039
from beets.util.config import sanitize_choices
4140

42-
from ._utils.requests import CaptchaError, HTTPNotFoundError, TimeoutSession
41+
from ._utils.requests import HTTPNotFoundError, RequestHandler
4342

4443
if TYPE_CHECKING:
4544
from collections.abc import Iterable, Iterator
4645

46+
import confuse
47+
4748
from beets.importer import ImportTask
4849
from beets.library import Item, Library
4950
from beets.logging import BeetsLogger as Logger
@@ -59,7 +60,9 @@
5960
INSTRUMENTAL_LYRICS = "[Instrumental]"
6061

6162

62-
r_session = TimeoutSession()
63+
class CaptchaError(requests.exceptions.HTTPError):
64+
def __init__(self, *args, **kwargs) -> None:
65+
super().__init__("Captcha is required", *args, **kwargs)
6366

6467

6568
# Utilities.
@@ -155,9 +158,18 @@ def slug(text: str) -> str:
155158
return re.sub(r"\W+", "-", unidecode(text).lower().strip()).strip("-")
156159

157160

158-
class RequestHandler:
161+
class LyricsRequestHandler(RequestHandler):
159162
_log: Logger
160163

164+
def status_to_error(self, code: int) -> type[requests.HTTPError] | None:
165+
if err := super().status_to_error(code):
166+
return err
167+
168+
if 300 <= code < 400:
169+
return CaptchaError
170+
171+
return None
172+
161173
def debug(self, message: str, *args) -> None:
162174
"""Log a debug message with the class name."""
163175
self._log.debug(f"{self.__class__.__name__}: {message}", *args)
@@ -187,21 +199,21 @@ def fetch_text(
187199
"""
188200
url = self.format_url(url, params)
189201
self.debug("Fetching HTML from {}", url)
190-
r = r_session.get(url, **kwargs)
202+
r = self.get(url, **kwargs)
191203
r.encoding = None
192204
return r.text
193205

194206
def fetch_json(self, url: str, params: JSONDict | None = None, **kwargs):
195207
"""Return JSON data from the given URL."""
196208
url = self.format_url(url, params)
197209
self.debug("Fetching JSON from {}", url)
198-
return r_session.get(url, **kwargs).json()
210+
return super().fetch_json(url, **kwargs)
199211

200212
def post_json(self, url: str, params: JSONDict | None = None, **kwargs):
201213
"""Send POST request and return JSON response."""
202214
url = self.format_url(url, params)
203215
self.debug("Posting JSON to {}", url)
204-
return r_session.post(url, **kwargs).json()
216+
return self.request("post", url, **kwargs).json()
205217

206218
@contextmanager
207219
def handle_request(self) -> Iterator[None]:
@@ -220,8 +232,10 @@ def name(cls) -> str:
220232
return cls.__name__.lower()
221233

222234

223-
class Backend(RequestHandler, metaclass=BackendClass):
224-
def __init__(self, config, log):
235+
class Backend(LyricsRequestHandler, metaclass=BackendClass):
236+
config: confuse.Subview
237+
238+
def __init__(self, config: confuse.Subview, log: Logger) -> None:
225239
self._log = log
226240
self.config = config
227241

@@ -712,7 +726,7 @@ def scrape(cls, html: str) -> str | None:
712726

713727

714728
@dataclass
715-
class Translator(RequestHandler):
729+
class Translator(LyricsRequestHandler):
716730
TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate"
717731
LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$")
718732
SEPARATOR = " | "
@@ -922,7 +936,7 @@ def write(self, items: list[Item]) -> None:
922936
ui.print_(textwrap.dedent(text))
923937

924938

925-
class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
939+
class LyricsPlugin(LyricsRequestHandler, plugins.BeetsPlugin):
926940
BACKEND_BY_NAME = {
927941
b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch]
928942
}

beetsplug/musicbrainz.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from beets.util.id_extractors import extract_release_id
3737

3838
from ._utils.requests import HTTPNotFoundError, TimeoutSession
39+
from .lyrics import RequestHandler
3940

4041
if TYPE_CHECKING:
4142
from collections.abc import Iterable, Sequence
@@ -61,10 +62,6 @@
6162
}
6263

6364

64-
class LimiterTimeoutSession(LimiterMixin, TimeoutSession):
65-
pass
66-
67-
6865
RELEASE_INCLUDES = [
6966
"artists",
7067
"media",
@@ -103,25 +100,32 @@ class LimiterTimeoutSession(LimiterMixin, TimeoutSession):
103100
BROWSE_MAXTRACKS = 500
104101

105102

103+
class LimiterTimeoutSession(LimiterMixin, TimeoutSession):
104+
pass
105+
106+
106107
@dataclass
107-
class MusicBrainzAPI:
108+
class MusicBrainzAPI(RequestHandler):
109+
session_type = LimiterTimeoutSession
110+
108111
api_host: str
109112
rate_limit: float
110113

111114
@cached_property
112115
def session(self) -> LimiterTimeoutSession:
113-
return LimiterTimeoutSession(per_second=self.rate_limit)
116+
return self.session_type(per_second=self.rate_limit)
114117

115-
def _get(self, entity: str, **kwargs) -> JSONDict:
116-
return self.session.get(
117-
f"{self.api_host}/ws/2/{entity}", params={**kwargs, "fmt": "json"}
118-
).json()
119-
120-
def get_release(self, id_: str) -> JSONDict:
118+
def get_entity(self, entity: str, **kwargs) -> JSONDict:
121119
return self.group_relations(
122-
self._get(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES))
120+
self.fetch_json(
121+
f"{self.api_host}/ws/2/{entity}",
122+
params={**kwargs, "fmt": "json"},
123+
)
123124
)
124125

126+
def get_release(self, id_: str) -> JSONDict:
127+
return self.get_entity(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES))
128+
125129
@singledispatchmethod
126130
@classmethod
127131
def group_relations(cls, data: Any) -> Any:
@@ -166,12 +170,12 @@ def _(cls, data: JSONDict) -> JSONDict:
166170
return data
167171

168172
def get_recording(self, id_: str) -> JSONDict:
169-
return self._get(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES))
173+
return self.get_entity(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES))
170174

171175
def browse_recordings(self, **kwargs) -> list[JSONDict]:
172176
kwargs.setdefault("limit", BROWSE_CHUNKSIZE)
173177
kwargs.setdefault("inc", BROWSE_INCLUDES)
174-
return self._get("recording", **kwargs)["recordings"]
178+
return self.get_entity("recording", **kwargs)["recordings"]
175179

176180

177181
def _preferred_alias(
@@ -202,7 +206,7 @@ def _preferred_alias(
202206
if (
203207
alias["locale"] == locale
204208
and alias.get("primary")
205-
and alias.get("type", "").lower() not in ignored_alias_types
209+
and (alias.get("type") or "").lower() not in ignored_alias_types
206210
):
207211
matches.append(alias)
208212

@@ -853,7 +857,7 @@ def _search_api(
853857
self._log.debug(
854858
"Searching for MusicBrainz {}s with: {!r}", query_type, query
855859
)
856-
return self.api._get(
860+
return self.api.get_entity(
857861
query_type, query=query, limit=self.config["search_limit"].get()
858862
)[f"{query_type}s"]
859863

test/plugins/test_musicbrainz.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,7 +1053,7 @@ def test_get_album_criteria(
10531053

10541054
def test_item_candidates(self, monkeypatch, mb):
10551055
monkeypatch.setattr(
1056-
"beetsplug.musicbrainz.MusicBrainzAPI._get",
1056+
"beetsplug.musicbrainz.MusicBrainzAPI.fetch_json",
10571057
lambda *_, **__: {"recordings": [self.RECORDING]},
10581058
)
10591059

@@ -1064,7 +1064,7 @@ def test_item_candidates(self, monkeypatch, mb):
10641064

10651065
def test_candidates(self, monkeypatch, mb):
10661066
monkeypatch.setattr(
1067-
"beetsplug.musicbrainz.MusicBrainzAPI._get",
1067+
"beetsplug.musicbrainz.MusicBrainzAPI.fetch_json",
10681068
lambda *_, **__: {"releases": [{"id": self.mbid}]},
10691069
)
10701070
monkeypatch.setattr(

0 commit comments

Comments
 (0)