Skip to content

Commit 71b20ae

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 69dc06d commit 71b20ae

File tree

4 files changed

+174
-44
lines changed

4 files changed

+174
-44
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: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,25 @@
2626
from html import unescape
2727
from itertools import groupby
2828
from pathlib import Path
29-
from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple
29+
from typing import TYPE_CHECKING, NamedTuple
3030
from urllib.parse import quote, quote_plus, urlencode, urlparse
3131

3232
import langdetect
3333
import requests
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:
44+
from collections.abc import Iterable, Iterator
45+
46+
import confuse
47+
4548
from beets.importer import ImportTask
4649
from beets.library import Item, Library
4750
from beets.logging import BeetsLogger as Logger
@@ -57,7 +60,9 @@
5760
INSTRUMENTAL_LYRICS = "[Instrumental]"
5861

5962

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

6267

6368
# Utilities.
@@ -153,9 +158,18 @@ def slug(text: str) -> str:
153158
return re.sub(r"\W+", "-", unidecode(text).lower().strip()).strip("-")
154159

155160

156-
class RequestHandler:
161+
class LyricsRequestHandler(RequestHandler):
157162
_log: Logger
158163

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+
159173
def debug(self, message: str, *args) -> None:
160174
"""Log a debug message with the class name."""
161175
self._log.debug(f"{self.__class__.__name__}: {message}", *args)
@@ -185,21 +199,21 @@ def fetch_text(
185199
"""
186200
url = self.format_url(url, params)
187201
self.debug("Fetching HTML from {}", url)
188-
r = r_session.get(url, **kwargs)
202+
r = self.get(url, **kwargs)
189203
r.encoding = None
190204
return r.text
191205

192206
def fetch_json(self, url: str, params: JSONDict | None = None, **kwargs):
193207
"""Return JSON data from the given URL."""
194208
url = self.format_url(url, params)
195209
self.debug("Fetching JSON from {}", url)
196-
return r_session.get(url, **kwargs).json()
210+
return super().fetch_json(url, **kwargs)
197211

198212
def post_json(self, url: str, params: JSONDict | None = None, **kwargs):
199213
"""Send POST request and return JSON response."""
200214
url = self.format_url(url, params)
201215
self.debug("Posting JSON to {}", url)
202-
return r_session.post(url, **kwargs).json()
216+
return self.request("post", url, **kwargs).json()
203217

204218
@contextmanager
205219
def handle_request(self) -> Iterator[None]:
@@ -218,8 +232,10 @@ def name(cls) -> str:
218232
return cls.__name__.lower()
219233

220234

221-
class Backend(RequestHandler, metaclass=BackendClass):
222-
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:
223239
self._log = log
224240
self.config = config
225241

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

711727

712728
@dataclass
713-
class Translator(RequestHandler):
729+
class Translator(LyricsRequestHandler):
714730
TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate"
715731
LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$")
716732
SEPARATOR = " | "
@@ -918,7 +934,7 @@ def write(self, items: list[Item]) -> None:
918934
ui.print_(textwrap.dedent(text))
919935

920936

921-
class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
937+
class LyricsPlugin(LyricsRequestHandler, plugins.BeetsPlugin):
922938
BACKEND_BY_NAME = {
923939
b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch]
924940
}

beetsplug/musicbrainz.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from beets.util.id_extractors import extract_release_id
3535

3636
from ._utils.requests import HTTPNotFoundError, TimeoutSession
37+
from .lyrics import RequestHandler
3738

3839
if TYPE_CHECKING:
3940
from typing import Literal
@@ -58,10 +59,6 @@
5859
}
5960

6061

61-
class LimiterTimeoutSession(LimiterMixin, TimeoutSession):
62-
pass
63-
64-
6562
RELEASE_INCLUDES = [
6663
"artists",
6764
"media",
@@ -99,30 +96,36 @@ class LimiterTimeoutSession(LimiterMixin, TimeoutSession):
9996
BROWSE_MAXTRACKS = 500
10097

10198

99+
class LimiterTimeoutSession(LimiterMixin, TimeoutSession):
100+
pass
101+
102+
102103
@dataclass
103-
class MusicBrainzAPI:
104+
class MusicBrainzAPI(RequestHandler):
105+
session_type = LimiterTimeoutSession
106+
104107
api_host: str
105108
rate_limit: float
106109

107110
@cached_property
108111
def session(self) -> LimiterTimeoutSession:
109-
return LimiterTimeoutSession(per_second=self.rate_limit)
112+
return self.session_type(per_second=self.rate_limit)
110113

111-
def _get(self, entity: str, **kwargs) -> JSONDict:
112-
return self.session.get(
114+
def get_entity(self, entity: str, **kwargs) -> JSONDict:
115+
return self.fetch_json(
113116
f"{self.api_host}/ws/2/{entity}", params={**kwargs, "fmt": "json"}
114-
).json()
117+
)
115118

116119
def get_release(self, id_: str) -> JSONDict:
117-
return self._get(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES))
120+
return self.get_entity(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES))
118121

119122
def get_recording(self, id_: str) -> JSONDict:
120-
return self._get(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES))
123+
return self.get_entity(f"recording/{id_}", inc=" ".join(TRACK_INCLUDES))
121124

122125
def browse_recordings(self, **kwargs) -> list[JSONDict]:
123126
kwargs.setdefault("limit", BROWSE_CHUNKSIZE)
124127
kwargs.setdefault("inc", BROWSE_INCLUDES)
125-
return self._get("recording", **kwargs)["recordings"]
128+
return self.get_entity("recording", **kwargs)["recordings"]
126129

127130

128131
def _preferred_alias(aliases: list[JSONDict]):
@@ -149,7 +152,7 @@ def _preferred_alias(aliases: list[JSONDict]):
149152
if (
150153
alias["locale"] == locale
151154
and alias.get("primary")
152-
and alias.get("type", "").lower() not in ignored_alias_types
155+
and (alias.get("type") or "").lower() not in ignored_alias_types
153156
):
154157
matches.append(alias)
155158

@@ -790,7 +793,7 @@ def _search_api(
790793
self._log.debug(
791794
"Searching for MusicBrainz {}s with: {!r}", query_type, query
792795
)
793-
return self.api._get(
796+
return self.api.get_entity(
794797
query_type, query=query, limit=self.config["search_limit"].get()
795798
)[f"{query_type}s"]
796799

test/plugins/test_musicbrainz.py

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

10321032
def test_item_candidates(self, monkeypatch, mb):
10331033
monkeypatch.setattr(
1034-
"beetsplug.musicbrainz.MusicBrainzAPI._get",
1034+
"beetsplug.musicbrainz.MusicBrainzAPI.fetch_json",
10351035
lambda *_, **__: {"recordings": [self.RECORDING]},
10361036
)
10371037

@@ -1042,7 +1042,7 @@ def test_item_candidates(self, monkeypatch, mb):
10421042

10431043
def test_candidates(self, monkeypatch, mb):
10441044
monkeypatch.setattr(
1045-
"beetsplug.musicbrainz.MusicBrainzAPI._get",
1045+
"beetsplug.musicbrainz.MusicBrainzAPI.fetch_json",
10461046
lambda *_, **__: {"releases": [{"id": self.mbid}]},
10471047
)
10481048
monkeypatch.setattr(

0 commit comments

Comments
 (0)