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 # In order to verify the config we need to make sure the plugin is fully
# configured (plugins usually add the default configuration *after* # configured (plugins usually add the default configuration *after*
# calling super().__init__()). # 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. """Verify plugin configuration.
If deprecated 'source_weight' option is explicitly set by the user, they 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 Attempts to import the plugin module, locate the appropriate plugin class
within it, and return an instance. Handles import failures gracefully and within it, and return an instance. Handles import failures gracefully and
logs warnings for missing plugins or loading errors. 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:
try: try:
@ -429,7 +435,7 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
except Exception as exc: except Exception as exc:
raise PluginImportError(name) from exc raise PluginImportError(name) from exc
for obj in namespace.__dict__.values(): for obj in reversed(namespace.__dict__.values()):
if ( if (
inspect.isclass(obj) inspect.isclass(obj)
and not isinstance( and not isinstance(

View file

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

View file

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

View file

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

View file

@ -40,8 +40,8 @@ or your plugin subpackage
anymore. anymore.
The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to
extend the :class:`beets.plugins.BeetsPlugin` abstract base class [2]_ . For extend the |BeetsPlugin| abstract base class [2]_ . For instance, a minimal
instance, a minimal plugin without any functionality would look like this: plugin without any functionality would look like this:
.. code-block:: python .. code-block:: python
@ -52,6 +52,12 @@ instance, a minimal plugin without any functionality would look like this:
class MyAwesomePlugin(BeetsPlugin): class MyAwesomePlugin(BeetsPlugin):
pass 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 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 your ``beets`` (virtual) environment. To enable your plugin, add it it to the
beets configuration beets configuration

View file

@ -77,10 +77,10 @@ pluginpath
~~~~~~~~~~ ~~~~~~~~~~
Directories to search for plugins. Each Python file or directory in a plugin 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 path represents a plugin and should define a subclass of |BeetsPlugin|. A plugin
plugin can then be loaded by adding the filename to the ``plugins`` can then be loaded by adding the plugin name to the ``plugins`` configuration.
configuration. The plugin path can either be a single string or a list of The plugin path can either be a single string or a list of strings---so, if you
strings---so, if you have multiple paths, format them as a YAML list like so: 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 sphinx.ext import intersphinx
from typing_extensions import TypeAlias from typing_extensions import TypeAlias
from docs.conf import rst_epilog
BASE = Path(__file__).parent.parent.absolute() BASE = Path(__file__).parent.parent.absolute()
PYPROJECT = BASE / "pyproject.toml" PYPROJECT = BASE / "pyproject.toml"
CHANGELOG = BASE / "docs" / "changelog.rst" CHANGELOG = BASE / "docs" / "changelog.rst"
@ -104,11 +106,21 @@ def create_rst_replacements() -> list[Replacement]:
plugins = "|".join( plugins = "|".join(
r.split("/")[-1] for r in refs if r.startswith("plugins/") 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 [ 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) # :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]), lambda m: make_ref_link(m[2], m[1]),
), ),
# Convert command references to documentation URLs # Convert command references to documentation URLs