Merge remote-tracking branch 'origin' into discogs-anv-support

This commit is contained in:
Henry 2025-09-30 19:23:23 -07:00
commit 9371ab81ec
7 changed files with 135 additions and 64 deletions

3
.github/CODEOWNERS vendored
View file

@ -1,2 +1,5 @@
# assign the entire repo to the maintainers team # assign the entire repo to the maintainers team
* @beetbox/maintainers * @beetbox/maintainers
# Specific ownerships:
/beets/metadata_plugins.py @semohr

View file

@ -20,6 +20,8 @@ use {}-style formatting and can interpolate keywords arguments to the logging
calls (`debug`, `info`, etc). calls (`debug`, `info`, etc).
""" """
from __future__ import annotations
import threading import threading
from copy import copy from copy import copy
from logging import ( from logging import (
@ -32,8 +34,10 @@ from logging import (
Handler, Handler,
Logger, Logger,
NullHandler, NullHandler,
RootLogger,
StreamHandler, StreamHandler,
) )
from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload
__all__ = [ __all__ = [
"DEBUG", "DEBUG",
@ -49,8 +53,20 @@ __all__ = [
"getLogger", "getLogger",
] ]
if TYPE_CHECKING:
T = TypeVar("T")
from types import TracebackType
def logsafe(val): # see https://github.com/python/typeshed/blob/main/stdlib/logging/__init__.pyi
_SysExcInfoType = Union[
tuple[type[BaseException], BaseException, Union[TracebackType, None]],
tuple[None, None, None],
]
_ExcInfoType = Union[None, bool, _SysExcInfoType, BaseException]
_ArgsType = Union[tuple[object, ...], Mapping[str, object]]
def _logsafe(val: T) -> str | T:
"""Coerce `bytes` to `str` to avoid crashes solely due to logging. """Coerce `bytes` to `str` to avoid crashes solely due to logging.
This is particularly relevant for bytestring paths. Much of our code This is particularly relevant for bytestring paths. Much of our code
@ -83,40 +99,45 @@ class StrFormatLogger(Logger):
""" """
class _LogMessage: class _LogMessage:
def __init__(self, msg, args, kwargs): def __init__(
self,
msg: str,
args: _ArgsType,
kwargs: dict[str, Any],
):
self.msg = msg self.msg = msg
self.args = args self.args = args
self.kwargs = kwargs self.kwargs = kwargs
def __str__(self): def __str__(self):
args = [logsafe(a) for a in self.args] args = [_logsafe(a) for a in self.args]
kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()} kwargs = {k: _logsafe(v) for (k, v) in self.kwargs.items()}
return self.msg.format(*args, **kwargs) return self.msg.format(*args, **kwargs)
def _log( def _log(
self, self,
level, level: int,
msg, msg: object,
args, args: _ArgsType,
exc_info=None, exc_info: _ExcInfoType = None,
extra=None, extra: Mapping[str, Any] | None = None,
stack_info=False, stack_info: bool = False,
stacklevel: int = 1,
**kwargs, **kwargs,
): ):
"""Log msg.format(*args, **kwargs)""" """Log msg.format(*args, **kwargs)"""
m = self._LogMessage(msg, args, kwargs)
stacklevel = kwargs.pop("stacklevel", 1) if isinstance(msg, str):
stacklevel = {"stacklevel": stacklevel} msg = self._LogMessage(msg, args, kwargs)
return super()._log( return super()._log(
level, level,
m, msg,
(), (),
exc_info=exc_info, exc_info=exc_info,
extra=extra, extra=extra,
stack_info=stack_info, stack_info=stack_info,
**stacklevel, stacklevel=stacklevel,
) )
@ -156,9 +177,12 @@ my_manager = copy(Logger.manager)
my_manager.loggerClass = BeetsLogger my_manager.loggerClass = BeetsLogger
# Override the `getLogger` to use our machinery. @overload
def getLogger(name=None): # noqa def getLogger(name: str) -> BeetsLogger: ...
@overload
def getLogger(name: None = ...) -> RootLogger: ...
def getLogger(name=None) -> BeetsLogger | RootLogger: # noqa: N802
if name: if name:
return my_manager.getLogger(name) return my_manager.getLogger(name) # type: ignore[return-value]
else: else:
return Logger.root return Logger.root

View file

@ -22,6 +22,7 @@ import re
import sys import sys
from collections import defaultdict from collections import defaultdict
from functools import wraps from functools import wraps
from importlib import import_module
from pathlib import Path from pathlib import Path
from types import GenericAlias from types import GenericAlias
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
@ -365,11 +366,11 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
""" """
try: try:
try: try:
namespace = __import__(f"{PLUGIN_NAMESPACE}.{name}", None, None) namespace = import_module(f"{PLUGIN_NAMESPACE}.{name}")
except Exception as exc: except Exception as exc:
raise PluginImportError(name) from exc raise PluginImportError(name) from exc
for obj in getattr(namespace, name).__dict__.values(): for obj in namespace.__dict__.values():
if ( if (
inspect.isclass(obj) inspect.isclass(obj)
and not isinstance( and not isinstance(
@ -378,6 +379,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
and issubclass(obj, BeetsPlugin) and issubclass(obj, BeetsPlugin)
and obj != BeetsPlugin and obj != BeetsPlugin
and not inspect.isabstract(obj) and not inspect.isabstract(obj)
# Only consider this plugin's module or submodules to avoid
# conflicts when plugins import other BeetsPlugin classes
and (
obj.__module__ == namespace.__name__
or obj.__module__.startswith(f"{namespace.__name__}.")
)
): ):
return obj() return obj()

View file

@ -36,10 +36,10 @@ from beets.util.config import sanitize_pairs
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence from collections.abc import Iterable, Iterator, Sequence
from logging import Logger
from beets.importer import ImportSession, ImportTask from beets.importer import ImportSession, ImportTask
from beets.library import Album, Library from beets.library import Album, Library
from beets.logging import BeetsLogger as Logger
try: try:
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag

View file

@ -42,10 +42,9 @@ from beets.autotag.distance import string_dist
from beets.util.config import sanitize_choices from beets.util.config import sanitize_choices
if TYPE_CHECKING: if TYPE_CHECKING:
from logging import Logger
from beets.importer import ImportTask from beets.importer import ImportTask
from beets.library import Item, Library from beets.library import Item, Library
from beets.logging import BeetsLogger as Logger
from ._typing import ( from ._typing import (
GeniusAPI, GeniusAPI,
@ -186,7 +185,7 @@ def slug(text: str) -> str:
class RequestHandler: class RequestHandler:
_log: beets.logging.Logger _log: Logger
def debug(self, message: str, *args) -> None: def debug(self, message: str, *args) -> None:
"""Log a debug message with the class name.""" """Log a debug message with the class name."""

View file

@ -37,6 +37,8 @@ Bug fixes:
matches due to query escaping (single vs double quotes). matches due to query escaping (single vs double quotes).
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from - :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
artists but not labels. :bug:`5366` artists but not labels. :bug:`5366`
- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by
an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033`
For packagers: For packagers:
@ -54,6 +56,12 @@ Other changes:
- :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific - :class:`beets.metadata_plugin.MetadataSourcePlugin`: Remove discogs specific
disambiguation stripping. disambiguation stripping.
For developers and plugin authors:
- Typing improvements in ``beets/logging.py``: ``getLogger`` now returns
``BeetsLogger`` when called with a name, or ``RootLogger`` when called without
a name.
2.4.0 (September 13, 2025) 2.4.0 (September 13, 2025)
-------------------------- --------------------------

View file

@ -3,18 +3,21 @@
import logging as log import logging as log
import sys import sys
import threading import threading
import unittest from types import ModuleType
from io import StringIO from unittest.mock import patch
import pytest
import beets.logging as blog import beets.logging as blog
import beetsplug
from beets import plugins, ui from beets import plugins, ui
from beets.test import _common, helper from beets.test import _common, helper
from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin
class LoggingTest(unittest.TestCase): class TestStrFormatLogger:
def test_logging_management(self): """Tests for the custom str-formatting logger."""
def test_logger_creation(self):
l1 = log.getLogger("foo123") l1 = log.getLogger("foo123")
l2 = blog.getLogger("foo123") l2 = blog.getLogger("foo123")
assert l1 == l2 assert l1 == l2
@ -34,49 +37,76 @@ class LoggingTest(unittest.TestCase):
l6 = blog.getLogger() l6 = blog.getLogger()
assert l1 != l6 assert l1 != l6
def test_str_format_logging(self): @pytest.mark.parametrize(
logger = blog.getLogger("baz123") "level", [log.DEBUG, log.INFO, log.WARNING, log.ERROR]
stream = StringIO() )
handler = log.StreamHandler(stream) @pytest.mark.parametrize(
"msg, args, kwargs, expected",
[
("foo {} bar {}", ("oof", "baz"), {}, "foo oof bar baz"),
(
"foo {bar} baz {foo}",
(),
{"foo": "oof", "bar": "baz"},
"foo baz baz oof",
),
("no args", (), {}, "no args"),
("foo {} bar {baz}", ("oof",), {"baz": "baz"}, "foo oof bar baz"),
],
)
def test_str_format_logging(
self, level, msg, args, kwargs, expected, caplog
):
logger = blog.getLogger("test_logger")
logger.setLevel(level)
logger.addHandler(handler) with caplog.at_level(level, logger="test_logger"):
logger.propagate = False logger.log(level, msg, *args, **kwargs)
logger.warning("foo {} {bar}", "oof", bar="baz") assert caplog.records, "No log records were captured"
handler.flush() assert str(caplog.records[0].msg) == expected
assert stream.getvalue(), "foo oof baz"
class DummyModule(ModuleType):
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
plugins.BeetsPlugin.__init__(self, "dummy")
self.import_stages = [self.import_stage]
self.register_listener("dummy_event", self.listener)
def log_all(self, name):
self._log.debug("debug {}", name)
self._log.info("info {}", name)
self._log.warning("warning {}", name)
def commands(self):
cmd = ui.Subcommand("dummy")
cmd.func = lambda _, __, ___: self.log_all("cmd")
return (cmd,)
def import_stage(self, session, task):
self.log_all("import_stage")
def listener(self):
self.log_all("listener")
def __init__(self, *_, **__):
module_name = "beetsplug.dummy"
super().__init__(module_name)
self.DummyPlugin.__module__ = module_name
self.DummyPlugin = self.DummyPlugin
class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase): class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase):
plugin = "dummy" plugin = "dummy"
class DummyModule: @classmethod
class DummyPlugin(plugins.BeetsPlugin): def setUpClass(cls):
def __init__(self): patcher = patch.dict(sys.modules, {"beetsplug.dummy": DummyModule()})
plugins.BeetsPlugin.__init__(self, "dummy") patcher.start()
self.import_stages = [self.import_stage] cls.addClassCleanup(patcher.stop)
self.register_listener("dummy_event", self.listener)
def log_all(self, name): super().setUpClass()
self._log.debug("debug {}", name)
self._log.info("info {}", name)
self._log.warning("warning {}", name)
def commands(self):
cmd = ui.Subcommand("dummy")
cmd.func = lambda _, __, ___: self.log_all("cmd")
return (cmd,)
def import_stage(self, session, task):
self.log_all("import_stage")
def listener(self):
self.log_all("listener")
def setUp(self):
sys.modules["beetsplug.dummy"] = self.DummyModule
beetsplug.dummy = self.DummyModule
super().setUp()
def test_command_level0(self): def test_command_level0(self):
self.config["verbose"] = 0 self.config["verbose"] = 0