Load the last plugin class found in the namespace (#6100)

- Modified `_get_plugin` function to use `reversed()` when iterating
through `namespace.__dict__.values()`
- This ensures that we load _the last_ plugin class found in the
namespace.

Fixes #6093
This commit is contained in:
Šarūnas Nejus 2025-10-14 17:05:29 +01:00 committed by GitHub
commit ecea47320c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 47 additions and 19 deletions

View file

@ -228,9 +228,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
# In order to verify the config we need to make sure the plugin is fully
# configured (plugins usually add the default configuration *after*
# calling super().__init__()).
self.register_listener("pluginload", self.verify_config)
self.register_listener("pluginload", self._verify_config)
def verify_config(self, *_, **__) -> None:
def _verify_config(self, *_, **__) -> None:
"""Verify plugin configuration.
If deprecated 'source_weight' option is explicitly set by the user, they
@ -422,6 +422,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
Attempts to import the plugin module, locate the appropriate plugin class
within it, and return an instance. Handles import failures gracefully and
logs warnings for missing plugins or loading errors.
Note we load the *last* plugin class found in the plugin namespace. This
allows plugins to define helper classes that inherit from BeetsPlugin
without those being loaded as the main plugin class.
Returns None if the plugin could not be loaded for any reason.
"""
try:
try:
@ -429,7 +435,7 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
except Exception as exc:
raise PluginImportError(name) from exc
for obj in namespace.__dict__.values():
for obj in reversed(namespace.__dict__.values()):
if (
inspect.isclass(obj)
and not isinstance(

View file

@ -15,6 +15,9 @@ New features:
Bug fixes:
- |BeetsPlugin|: load the last plugin class defined in the plugin namespace.
:bug:`6093`
For packagers:
- Fixed dynamic versioning install not disabled for source distribution builds.
@ -23,7 +26,7 @@ For packagers:
Other changes:
- Removed outdated mailing list contact information from the documentation
(:bug:`5462`).
:bug:`5462`.
- :doc:`guides/main`: Modernized the *Getting Started* guide with tabbed
sections and dropdown menus. Installation instructions have been streamlined,
and a new subpage now provides additional setup details.
@ -66,7 +69,7 @@ Bug fixes:
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
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`
an import of another |BeetsPlugin| class. :bug:`6033`
- :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor
regexps, allow for more cases, add some logging), add tests.
- Metadata source plugins: Fixed data source penalty calculation that was
@ -188,8 +191,8 @@ For plugin developers:
art sources might need to be adapted.
- We split the responsibilities of plugins into two base classes
1. :class:`beets.plugins.BeetsPlugin` is the base class for all plugins, any
plugin needs to inherit from this class.
1. |BeetsPlugin| is the base class for all plugins, any plugin needs to
inherit from this class.
2. :class:`beets.metadata_plugin.MetadataSourcePlugin` allows plugins to act
like metadata sources. E.g. used by the MusicBrainz plugin. All plugins in
the beets repo are opted into this class where applicable. If you are
@ -5072,7 +5075,7 @@ BPD). To "upgrade" an old database, you can use the included ``albumify`` plugin
list of plugin names) and ``pluginpath`` (a colon-separated list of
directories to search beyond ``sys.path``). Plugins are just Python modules
under the ``beetsplug`` namespace package containing subclasses of
``beets.plugins.BeetsPlugin``. See `the beetsplug directory`_ for examples or
|BeetsPlugin|. See `the beetsplug directory`_ for examples or
:doc:`/plugins/index` for instructions.
- As a consequence of adding album art, the database was significantly
refactored to keep track of some information at an album (rather than item)

View file

@ -82,6 +82,7 @@ man_pages = [
rst_epilog = """
.. |Album| replace:: :class:`~beets.library.models.Album`
.. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo`
.. |BeetsPlugin| replace:: :class:`beets.plugins.BeetsPlugin`
.. |ImportSession| replace:: :class:`~beets.importer.session.ImportSession`
.. |ImportTask| replace:: :class:`~beets.importer.tasks.ImportTask`
.. |Item| replace:: :class:`~beets.library.models.Item`

View file

@ -95,9 +95,9 @@ starting points include:
Migration guidance
------------------
Older metadata plugins that extend :py:class:`beets.plugins.BeetsPlugin` should
be migrated to :py:class:`MetadataSourcePlugin`. Legacy support will be removed
in **beets v3.0.0**.
Older metadata plugins that extend |BeetsPlugin| should be migrated to
:py:class:`MetadataSourcePlugin`. Legacy support will be removed in **beets
v3.0.0**.
.. seealso::

View file

@ -40,8 +40,8 @@ or your plugin subpackage
anymore.
The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to
extend the :class:`beets.plugins.BeetsPlugin` abstract base class [2]_ . For
instance, a minimal plugin without any functionality would look like this:
extend the |BeetsPlugin| abstract base class [2]_ . For instance, a minimal
plugin without any functionality would look like this:
.. code-block:: python
@ -52,6 +52,12 @@ instance, a minimal plugin without any functionality would look like this:
class MyAwesomePlugin(BeetsPlugin):
pass
.. attention::
If your plugin is composed of intermediate |BeetsPlugin| subclasses, make
sure that your plugin is defined *last* in the namespace. We only load the
last subclass of |BeetsPlugin| we find in your plugin namespace.
To use your new plugin, you need to package [3]_ your plugin and install it into
your ``beets`` (virtual) environment. To enable your plugin, add it it to the
beets configuration

View file

@ -77,10 +77,10 @@ pluginpath
~~~~~~~~~~
Directories to search for plugins. Each Python file or directory in a plugin
path represents a plugin and should define a subclass of :class:`BeetsPlugin`. A
plugin can then be loaded by adding the filename to the ``plugins``
configuration. The plugin path can either be a single string or a list of
strings---so, if you have multiple paths, format them as a YAML list like so:
path represents a plugin and should define a subclass of |BeetsPlugin|. A plugin
can then be loaded by adding the plugin name to the ``plugins`` configuration.
The plugin path can either be a single string or a list of strings---so, if you
have multiple paths, format them as a YAML list like so:
::

View file

@ -19,6 +19,8 @@ from packaging.version import Version, parse
from sphinx.ext import intersphinx
from typing_extensions import TypeAlias
from docs.conf import rst_epilog
BASE = Path(__file__).parent.parent.absolute()
PYPROJECT = BASE / "pyproject.toml"
CHANGELOG = BASE / "docs" / "changelog.rst"
@ -104,11 +106,21 @@ def create_rst_replacements() -> list[Replacement]:
plugins = "|".join(
r.split("/")[-1] for r in refs if r.startswith("plugins/")
)
explicit_replacements = dict(
line.removeprefix(".. ").split(" replace:: ")
for line in filter(None, rst_epilog.splitlines())
)
return [
# Replace Sphinx :ref: and :doc: directives by documentation URLs
# Replace explicitly defined substitutions from rst_epilog
# |BeetsPlugin| -> :class:`beets.plugins.BeetsPlugin`
(
r"\|\w[^ ]*\|",
lambda m: explicit_replacements.get(m[0], m[0]),
),
# Replace Sphinx directives by documentation URLs, e.g.,
# :ref:`/plugins/autobpm` -> [AutoBPM Plugin](DOCS/plugins/autobpm.html)
(
r":(?:ref|doc):`+(?:([^`<]+)<)?/?([\w./_-]+)>?`+",
r":(?:ref|doc|class):`+(?:([^`<]+)<)?/?([\w./_-]+)>?`+",
lambda m: make_ref_link(m[2], m[1]),
),
# Convert command references to documentation URLs