Skip to content

Commit b06f3f6

Browse files
authored
Improved beets/logging.py typing (#6032)
This PR enhances `beets/logging.py` with improved typing and tests: * `getLogger` now returns the precise logger type (`BeetsLogger` or `RootLogger`). * Tests use `pytest` and `parametrize` for more concise and readable coverage.
2 parents 689ec10 + 89c2e10 commit b06f3f6

File tree

6 files changed

+88
-37
lines changed

6 files changed

+88
-37
lines changed

.github/CODEOWNERS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
# assign the entire repo to the maintainers team
22
* @beetbox/maintainers
3+
4+
# Specific ownerships:
5+
/beets/metadata_plugins.py @semohr

beets/logging.py

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
calls (`debug`, `info`, etc).
2121
"""
2222

23+
from __future__ import annotations
24+
2325
import threading
2426
from copy import copy
2527
from logging import (
@@ -32,8 +34,10 @@
3234
Handler,
3335
Logger,
3436
NullHandler,
37+
RootLogger,
3538
StreamHandler,
3639
)
40+
from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload
3741

3842
__all__ = [
3943
"DEBUG",
@@ -49,8 +53,20 @@
4953
"getLogger",
5054
]
5155

56+
if TYPE_CHECKING:
57+
T = TypeVar("T")
58+
from types import TracebackType
59+
60+
# see https://github.com/python/typeshed/blob/main/stdlib/logging/__init__.pyi
61+
_SysExcInfoType = Union[
62+
tuple[type[BaseException], BaseException, Union[TracebackType, None]],
63+
tuple[None, None, None],
64+
]
65+
_ExcInfoType = Union[None, bool, _SysExcInfoType, BaseException]
66+
_ArgsType = Union[tuple[object, ...], Mapping[str, object]]
67+
5268

53-
def logsafe(val):
69+
def _logsafe(val: T) -> str | T:
5470
"""Coerce `bytes` to `str` to avoid crashes solely due to logging.
5571
5672
This is particularly relevant for bytestring paths. Much of our code
@@ -83,40 +99,45 @@ class StrFormatLogger(Logger):
8399
"""
84100

85101
class _LogMessage:
86-
def __init__(self, msg, args, kwargs):
102+
def __init__(
103+
self,
104+
msg: str,
105+
args: _ArgsType,
106+
kwargs: dict[str, Any],
107+
):
87108
self.msg = msg
88109
self.args = args
89110
self.kwargs = kwargs
90111

91112
def __str__(self):
92-
args = [logsafe(a) for a in self.args]
93-
kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()}
113+
args = [_logsafe(a) for a in self.args]
114+
kwargs = {k: _logsafe(v) for (k, v) in self.kwargs.items()}
94115
return self.msg.format(*args, **kwargs)
95116

96117
def _log(
97118
self,
98-
level,
99-
msg,
100-
args,
101-
exc_info=None,
102-
extra=None,
103-
stack_info=False,
119+
level: int,
120+
msg: object,
121+
args: _ArgsType,
122+
exc_info: _ExcInfoType = None,
123+
extra: Mapping[str, Any] | None = None,
124+
stack_info: bool = False,
125+
stacklevel: int = 1,
104126
**kwargs,
105127
):
106128
"""Log msg.format(*args, **kwargs)"""
107-
m = self._LogMessage(msg, args, kwargs)
108129

109-
stacklevel = kwargs.pop("stacklevel", 1)
110-
stacklevel = {"stacklevel": stacklevel}
130+
if isinstance(msg, str):
131+
msg = self._LogMessage(msg, args, kwargs)
111132

112133
return super()._log(
113134
level,
114-
m,
135+
msg,
115136
(),
116137
exc_info=exc_info,
117138
extra=extra,
118139
stack_info=stack_info,
119-
**stacklevel,
140+
stacklevel=stacklevel,
120141
)
121142

122143

@@ -156,9 +177,12 @@ class BeetsLogger(ThreadLocalLevelLogger, StrFormatLogger):
156177
my_manager.loggerClass = BeetsLogger
157178

158179

159-
# Override the `getLogger` to use our machinery.
160-
def getLogger(name=None): # noqa
180+
@overload
181+
def getLogger(name: str) -> BeetsLogger: ...
182+
@overload
183+
def getLogger(name: None = ...) -> RootLogger: ...
184+
def getLogger(name=None) -> BeetsLogger | RootLogger: # noqa: N802
161185
if name:
162-
return my_manager.getLogger(name)
186+
return my_manager.getLogger(name) # type: ignore[return-value]
163187
else:
164188
return Logger.root

beetsplug/fetchart.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@
3636

3737
if TYPE_CHECKING:
3838
from collections.abc import Iterable, Iterator, Sequence
39-
from logging import Logger
4039

4140
from beets.importer import ImportSession, ImportTask
4241
from beets.library import Album, Library
42+
from beets.logging import BeetsLogger as Logger
4343

4444
try:
4545
from bs4 import BeautifulSoup, Tag

beetsplug/lyrics.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,9 @@
4242
from beets.util.config import sanitize_choices
4343

4444
if TYPE_CHECKING:
45-
from logging import Logger
46-
4745
from beets.importer import ImportTask
4846
from beets.library import Item, Library
47+
from beets.logging import BeetsLogger as Logger
4948

5049
from ._typing import (
5150
GeniusAPI,
@@ -186,7 +185,7 @@ def slug(text: str) -> str:
186185

187186

188187
class RequestHandler:
189-
_log: beets.logging.Logger
188+
_log: Logger
190189

191190
def debug(self, message: str, *args) -> None:
192191
"""Log a debug message with the class name."""

docs/changelog.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ Other changes:
5151
- :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific
5252
disambiguation stripping.
5353

54+
For developers and plugin authors:
55+
56+
- Typing improvements in ``beets/logging.py``: ``getLogger`` now returns
57+
``BeetsLogger`` when called with a name, or ``RootLogger`` when called without
58+
a name.
59+
5460
2.4.0 (September 13, 2025)
5561
--------------------------
5662

test/test_logging.py

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@
33
import logging as log
44
import sys
55
import threading
6-
import unittest
7-
from io import StringIO
86
from types import ModuleType
97
from unittest.mock import patch
108

9+
import pytest
10+
1111
import beets.logging as blog
1212
from beets import plugins, ui
1313
from beets.test import _common, helper
1414
from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin
1515

1616

17-
class LoggingTest(unittest.TestCase):
18-
def test_logging_management(self):
17+
class TestStrFormatLogger:
18+
"""Tests for the custom str-formatting logger."""
19+
20+
def test_logger_creation(self):
1921
l1 = log.getLogger("foo123")
2022
l2 = blog.getLogger("foo123")
2123
assert l1 == l2
@@ -35,17 +37,34 @@ def test_logging_management(self):
3537
l6 = blog.getLogger()
3638
assert l1 != l6
3739

38-
def test_str_format_logging(self):
39-
logger = blog.getLogger("baz123")
40-
stream = StringIO()
41-
handler = log.StreamHandler(stream)
42-
43-
logger.addHandler(handler)
44-
logger.propagate = False
45-
46-
logger.warning("foo {} {bar}", "oof", bar="baz")
47-
handler.flush()
48-
assert stream.getvalue(), "foo oof baz"
40+
@pytest.mark.parametrize(
41+
"level", [log.DEBUG, log.INFO, log.WARNING, log.ERROR]
42+
)
43+
@pytest.mark.parametrize(
44+
"msg, args, kwargs, expected",
45+
[
46+
("foo {} bar {}", ("oof", "baz"), {}, "foo oof bar baz"),
47+
(
48+
"foo {bar} baz {foo}",
49+
(),
50+
{"foo": "oof", "bar": "baz"},
51+
"foo baz baz oof",
52+
),
53+
("no args", (), {}, "no args"),
54+
("foo {} bar {baz}", ("oof",), {"baz": "baz"}, "foo oof bar baz"),
55+
],
56+
)
57+
def test_str_format_logging(
58+
self, level, msg, args, kwargs, expected, caplog
59+
):
60+
logger = blog.getLogger("test_logger")
61+
logger.setLevel(level)
62+
63+
with caplog.at_level(level, logger="test_logger"):
64+
logger.log(level, msg, *args, **kwargs)
65+
66+
assert caplog.records, "No log records were captured"
67+
assert str(caplog.records[0].msg) == expected
4968

5069

5170
class DummyModule(ModuleType):

0 commit comments

Comments
 (0)