Skip to content

Conversation

@semohr
Copy link
Contributor

@semohr semohr commented Sep 19, 2025

It seems like when we simplified the plugin loading, we introduced a regression (#6033).

This PR introduces a fix:
We now consider publicly exposed classes listed in a plugin module’s __all__.

  • If __all__ is defined, only those members are scanned for BeetsPlugin subclasses.
  • If multiple plugin classes are exported, we fail fast with a clear error.
  • If no __all__ is defined, we fall back to scanning the full module (preserving backward compatibility).

This makes plugin loading predictable and avoids cases where helper/internal classes are picked up by mistake.

It seems like the chroma and bpsync plugins exposed an additional BeetsPlugin class. Luckily, the TestImportPlugin test already catches this error if it is raised 👍

@snejus I think this takes precedence and should be prioritized. Maybe we also do a 2.4.1 release here?

closes #6033
closes #6023

Copilot AI review requested due to automatic review settings September 19, 2025 17:11
@semohr semohr requested a review from a team as a code owner September 19, 2025 17:11
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Sep 19, 2025

Reviewer's Guide

Enhance plugin loading by honoring a module’s all for class discovery with fallback to full module, introduce a clear fail-fast error when multiple BeetsPlugin subclasses are exported, add explicit all definitions to chroma and bpsync plugins, and update the changelog.

File-Level Changes

Change Details Files
Respect all when scanning for BeetsPlugin subclasses with fallback to full module
  • Retrieve module.all or use module.dict if undefined
  • Filter members for non-abstract BeetsPlugin subclasses excluding the base class
  • Return None when no valid plugin class is found
beets/plugins.py
Fail fast on multiple plugin classes exported
  • Log a debug hint to export only one plugin class
  • Raise PluginImportError listing all discovered plugin class names
beets/plugins.py
Add explicit all declarations to limit plugin exports
  • Define all = ['BPSyncPlugin'] in bpsync plugin
  • Define all = ['AcoustidPlugin'] in chroma plugin
beetsplug/bpsync.py
beetsplug/chroma.py
Update changelog to reflect plugin loading fix
  • Add entry for regression fix and new loading behavior
docs/changelog.rst

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@semohr semohr requested a review from snejus September 19, 2025 17:11
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR fixes a regression in plugin loading where plugins with multiple BeetsPlugin classes could not be loaded properly. The fix introduces support for the __all__ module attribute to explicitly control which plugin classes are exposed, preventing conflicts from helper or internal classes.

Key changes:

  • Enhanced plugin loading logic to respect __all__ exports when present
  • Added validation to prevent multiple plugin classes from being loaded from a single module
  • Updated affected plugins (chroma and bpsync) to explicitly export their main plugin classes

Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
beets/plugins.py Modified _get_plugin function to check __all__ exports and validate single plugin class
beetsplug/chroma.py Added __all__ to export only AcoustidPlugin
beetsplug/bpsync.py Added __all__ to export only BPSyncPlugin
docs/changelog.rst Added changelog entry documenting the bug fix

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

beets/plugins.py Outdated
Comment on lines 373 to 374
exports = getattr(module, "__all__", module.__dict__)
members = {key: getattr(module, key) for key in exports}
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When __all__ is a list of strings (the typical case), this code will fail because it tries to use the strings as dictionary keys against module.__dict__. The code should handle __all__ as an iterable of attribute names, not as a dictionary. Consider: members = {key: getattr(module, key) for key in (exports if hasattr(module, '__all__') else module.__dict__)}

Suggested change
exports = getattr(module, "__all__", module.__dict__)
members = {key: getattr(module, key) for key in exports}
if hasattr(module, "__all__"):
members = {key: getattr(module, key) for key in module.__all__}
else:
members = {key: getattr(module, key) for key in module.__dict__}

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to work like this for me... AI might be wrong here.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • Before building the members dict from __all__, validate that each entry is a string and actually exists on the module to avoid KeyError or AttributeError at runtime.
  • Consider replacing the filter+lambda with a concise list comprehension for collecting plugin_classes to improve readability and maintainability.
  • When falling back to scanning the full module (no __all__), filter out private/internal names (e.g., starting with _) to reduce the risk of accidentally picking up helper classes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Before building the members dict from `__all__`, validate that each entry is a string and actually exists on the module to avoid KeyError or AttributeError at runtime.
- Consider replacing the `filter`+`lambda` with a concise list comprehension for collecting `plugin_classes` to improve readability and maintainability.
- When falling back to scanning the full module (no `__all__`), filter out private/internal names (e.g., starting with `_`) to reduce the risk of accidentally picking up helper classes.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@semohr semohr changed the title Fixed issue with plugins not loaded if multiple plugins are defined in a plugins file Fixed issue with plugins not loaded if multiple plugins are exported in a plugins file Sep 19, 2025
@codecov
Copy link

codecov bot commented Sep 19, 2025

Codecov Report

❌ Patch coverage is 87.50000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 66.73%. Comparing base (b06f3f6) to head (61a5e01).
⚠️ Report is 84 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beets/plugins.py 87.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6034      +/-   ##
==========================================
- Coverage   66.92%   66.73%   -0.19%     
==========================================
  Files         117      119       +2     
  Lines       18133    18148      +15     
  Branches     3076     3076              
==========================================
- Hits        12135    12111      -24     
- Misses       5338     5378      +40     
+ Partials      660      659       -1     
Files with missing lines Coverage Δ
beets/plugins.py 85.47% <87.50%> (+0.31%) ⬆️

... and 9 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@snejus
Copy link
Member

snejus commented Sep 19, 2025

Does this not suffice?

                 and issubclass(obj, BeetsPlugin)
                 and obj != BeetsPlugin
                 and not inspect.isabstract(obj)
+                and obj.__module__ == namespace.__name__

@semohr
Copy link
Contributor Author

semohr commented Sep 19, 2025

Does this not suffice?

                 and issubclass(obj, BeetsPlugin)
                 and obj != BeetsPlugin
                 and not inspect.isabstract(obj)
+                and obj.__module__ == namespace.__name__

I was thinking about this too but I don't think this will work. Plugins which are spread across different files and are only exposed in an __init__.py file might be an issue. The module does not need to match the namespace in this case.

Haven't tested it tho, just thought about it quickly and disregarded it. I might be wrong here.

@snejus
Copy link
Member

snejus commented Sep 19, 2025

Does this not suffice?

                 and issubclass(obj, BeetsPlugin)
                 and obj != BeetsPlugin
                 and not inspect.isabstract(obj)
+                and obj.__module__ == namespace.__name__

I was thinking about this too but I don't think this will work. Plugins which are spread across different files and are only exposed in an __init__.py file might be an issue. The module does not need to match the namespace in this case.

Haven't tested it tho, just thought about it quickly and disregarded it. I might be wrong here.

>>> namespace = import_module("beetsplug.lastgenre")

>>> namespace.__name__
'beetsplug.lastgenre'

>>> namespace.LastGenrePlugin.__module__
'beetsplug.lastgenre'

Notably, lastgenre plugin is defined in beetsplug/lastgenre/__init__.py. Our plugin loading mechanism is based on plugins being defined in beetsplug.<plugin-name> modules, so we can trust it.

The full diff

diff --git a/beets/plugins.py b/beets/plugins.py
index d8d465183..4751ac7f3 100644
--- a/beets/plugins.py
+++ b/beets/plugins.py
@@ -23,4 +23,5 @@
 from collections import defaultdict
 from functools import wraps
+from importlib import import_module
 from pathlib import Path
 from types import GenericAlias
@@ -366,9 +367,9 @@ def _get_plugin
     try:
         try:
-            namespace = __import__(f"{PLUGIN_NAMESPACE}.{name}", None, None)
+            namespace = import_module(f"{PLUGIN_NAMESPACE}.{name}")
         except Exception as exc:
             raise PluginImportError(name) from exc
 
-        for obj in getattr(namespace, name).__dict__.values():
+        for obj in namespace.__dict__.values():
             if (
                 inspect.isclass(obj)
@@ -379,4 +380,5 @@ def _get_plugin
                 and obj != BeetsPlugin
                 and not inspect.isabstract(obj)
+                and namespace.__name__ == obj.__module__
             ):
                 return obj()

@semohr
Copy link
Contributor Author

semohr commented Sep 20, 2025

In this specific case it works, but the more general case does not. If the plugin class is not defined directly in the __init__.py file but instead imported and re-exported from another module, the __module__ attribute points to the original file rather than the package namespace.

For example:

# __init__.py
from .foo import MyPlugin

__all__ = ["MyPlugin"]
# foo.py
class MyPlugin:
    pass

When loaded:

namespace = importlib.import_module("beetsplug.test")
namespace.__name__
>>> 'beetsplug.test'

namespace.MyPlugin.__module__
>>> 'beetsplug.test.foo'

This means that relying on __module__ directly will not always yield the intended package name. While we could work around this with something like a split or startswith, that still doesn’t fully solve the problem.

A key issue is that there may be multiple plugin classes defined in different files or with inheritance relationships, and only a one should be exposed. For this reason, we should explicitly support and prioritize __all__, since it clearly defines the intended public API of the plugin package.

@snejus
Copy link
Member

snejus commented Sep 20, 2025

In this specific case it works, but the more general case does not. If the plugin class is not defined directly in the __init__.py file but instead imported and re-exported from another module, the __module__ attribute points to the original file rather than the package namespace.

For example:

# __init__.py
from .foo import MyPlugin

__all__ = ["MyPlugin"]
```.

I don't think we need to be overly defensive here and account for such a use case, since it adds a fair bit of complexity.

I haven't seen such an import in the wild, and our docs clearly specify that the plugin needs to be defined either in plugin.py or plugin/__init__.py module.

@semohr
Copy link
Contributor Author

semohr commented Sep 20, 2025

I haven't seen such an import in the wild

I'm doing this in the aisauce plugin 🥲

Edit:
Other plugins I found which do that:

  • beets-mpd-utils
  • beets-gogdsync
  • beets-stylize
  • beets-importmodifyinfo

@semohr
Copy link
Contributor Author

semohr commented Sep 20, 2025

I don't think we need to be overly defensive here and account for such a use case, since it adds a fair bit of complexity.

I kinda think we do have to be a bit defensive here. Raising an error here is the proper way to do it. Some developer will run into this issue down the line and we can spare them some time debugging this. Also we will break some plugin otherwise 😨

@snejus
Copy link
Member

snejus commented Sep 20, 2025

Thanks, I stand corrected! I do still think the solution can be simpler:

diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py
index 9ae6d47d5..e49986798 100644
--- a/beetsplug/bpsync.py
+++ b/beetsplug/bpsync.py
@@ -20 +20 @@
-from .beatport import BeatportPlugin
+from . import beatport
@@ -26 +26 @@ def __init__
-        self.beatport_plugin = BeatportPlugin()
+        self.beatport_plugin = beatport.BeatportPlugin()
@@ -100 +100 @@ def is_beatport_track
-            item.get("data_source") == BeatportPlugin.data_source
+            item.get("data_source") == beatport.BeatportPlugin.data_source
diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py
index 192310fb8..c15985b58 100644
--- a/beetsplug/chroma.py
+++ b/beetsplug/chroma.py
@@ -31 +31 @@
-from beetsplug.musicbrainz import MusicBrainzPlugin
+from beetsplug import musicbrainz
@@ -191,2 +191,2 @@ def __init__
-    def mb(self) -> MusicBrainzPlugin:
-        return MusicBrainzPlugin()
+    def mb(self) -> musicbrainz.MusicBrainzPlugin:
+        return musicbrainz.MusicBrainzPlugin()

@semohr
Copy link
Contributor Author

semohr commented Sep 20, 2025

Thanks, I stand corrected! I do still think the solution can be simpler:

diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py
index 9ae6d47d5..e49986798 100644
--- a/beetsplug/bpsync.py
+++ b/beetsplug/bpsync.py
@@ -20 +20 @@
-from .beatport import BeatportPlugin
+from . import beatport
@@ -26 +26 @@ def __init__
-        self.beatport_plugin = BeatportPlugin()
+        self.beatport_plugin = beatport.BeatportPlugin()
@@ -100 +100 @@ def is_beatport_track
-            item.get("data_source") == BeatportPlugin.data_source
+            item.get("data_source") == beatport.BeatportPlugin.data_source
diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py
index 192310fb8..c15985b58 100644
--- a/beetsplug/chroma.py
+++ b/beetsplug/chroma.py
@@ -31 +31 @@
-from beetsplug.musicbrainz import MusicBrainzPlugin
+from beetsplug import musicbrainz
@@ -191,2 +191,2 @@ def __init__
-    def mb(self) -> MusicBrainzPlugin:
-        return MusicBrainzPlugin()
+    def mb(self) -> musicbrainz.MusicBrainzPlugin:
+        return musicbrainz.MusicBrainzPlugin()

You are not a fan of __all__ ? It is pretty common imo.

We still need some kind of fix for the actual core plugin loading tho, right? Earlier this wasn't an issue since we had a dictionary of plugin instances, this was changed to a list in 2.4.0 and now we run into the plugin dupe issue 🤔

@snejus
Copy link
Member

snejus commented Sep 20, 2025

You are not a fan of __all__ ? It is pretty common imo.

I am a fan of simple solutions, rather 😅

We still need some kind of fix for the actual core plugin loading tho, right? Earlier this wasn't an issue since we had a dictionary of plugin instances, this was changes to a list in 2.4.0 and now we run into the plugin dupe issue 🤔

I doubt this would be helpful, since chroma gets loaded before musicbrainz (unless the user configures the plugin list in a non-alphabetical order, ew 😁 )

@semohr
Copy link
Contributor Author

semohr commented Sep 20, 2025

Fair! Although if we go for this approach, there is the possibility for it to happen again in the future without tests catching it. I'm more in favor of raising some kind of error if two BeetsPlugins are detected. This way we will never run into this issue again and our tests are able to catch it early.

Also we can only fix it for internal plugins. External ones might still be broken.

Was hard enough to triag this.

@snejus
Copy link
Member

snejus commented Sep 20, 2025

What about a note in the docs that says 'if your plugin depends on an internal plugin, import the plugin's module' with an example?

@semohr
Copy link
Contributor Author

semohr commented Sep 20, 2025

What about a note in the docs that says 'if your plugin depends on an internal plugin, import the plugin's module' with an example?

Seems a bit hacky to me since we already have the fix here 🙃 Also this is a regression in 2.4.0 so we should probably restore the original behavior. You that much against the a bit more verbose solution?

The only change in the logic is that we now checks the __all__ first before checking all defined types in __dict__.

@snejus
Copy link
Member

snejus commented Sep 20, 2025

You that much against the a bit more verbose solution?

Yes I am, when it fixes an issue which does not exist (once the imports are adjusted as specified in the message above).

@snejus
Copy link
Member

snejus commented Sep 20, 2025

Sorry for being an ass - I'm just mindful that we have more than enough complexity in the code base, so unless we have no other option, let's keep it simple and stupid.

@semohr
Copy link
Contributor Author

semohr commented Sep 20, 2025

Lol the issue still exists it is just not visible anymore. Just sweeped under the carpet. E.g. external plugins could still show the same pattern...

Why don't you want to fix the underlying issue and make transparent what is happening? This feels like such strange and unmaintainable decicion to me...

@semohr
Copy link
Contributor Author

semohr commented Sep 20, 2025

Sorry for being an ass - I'm just mindful that we have more than enough complexity in the code base, so unless we have no other option, let's keep it simple and stupid.

My problem with the solution is just that the acctual underlying issue is not fixed at all. With the "easier" solution we are just treating symptoms imo.

Maybe a bit of outside perspective could help? @beetbox/maintainers someone else wants to have a look here?

@snejus
Copy link
Member

snejus commented Sep 21, 2025

Lol the issue still exists it is just not visible anymore. Just sweeped under the carpet. E.g. external plugins could still show the same pattern...

Look, a couple of messages above I changed my mind once you've given me evidence regarding

I'm doing this in the aisauce plugin 🥲

Edit: Other plugins I found which do that:

* beets-mpd-utils

* beets-gogdsync

* beets-stylize

* beets-importmodifyinfo

On a similar note, can you find an external plugin that is now broken because of this issue?

So far, this issue has been limited to two internal plugins, and we have a simple fix for it. In order to consider a more bulletproof/reliable (but more complex) solution, I want to see a real-world example that it's applicable to, rather than a theoretical one.

Copy link
Member

@snejus snejus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix to this regression should restore the previous behavior, rather than requiring the ecosystem to adapt to this issue. This shifts the burden to plugin developers and requires them to adjust their source code.

We have previously been able to load a plugin regardless of whether any other plugin was imported into the same module. We want to continue being able to do so, and we do not want to depend on plugin developers' due diligence to ensure it.

This check should be a good candidate

obj.__module__ == namespace.__path__ or obj.__module__.startswith(f"{namespace.__path__}.")

@semohr
Copy link
Contributor Author

semohr commented Sep 21, 2025

obj.module == namespace.path or obj.module.startswith(f"{namespace.path}.")

Im fine with this instead. This is basically what I meant in #6034 (comment). This is a way better solution than changing imports in the plugins and hoping for the best.

I hope we can do more here tho, please read on!


After thinking about this more, I believe we’re dealing with two closely related (but not identical) issues. That might explain why we haven’t been converging so far. I would like to address both issues if possible with this PR.

a. A single plugin namespace should not export more than one plugin class (bijective mapping).
b. External plugin classes inside a plugin namespace should not be detected as plugins.

Fixing b alone would resolve the immediate problem, but a can still easily occur (for example, via inheritance/mixins). Enforcing a implicitly also fixes b and makes the plugin system more clearly defined, robust and should reduce friction for developers.

It also strengthens our public-facing API: if we only fix b, developers could still define multiple plugins in the same namespace, and the resulting behavior would remain ambiguous or unpredictable.


Option 1: __module__ check
obj.__module__ == namespace.__path__ or obj.__module__.startswith(f"{namespace.__path__}.")

  • Fixes b, but still allows multiple plugins to be exported.
  • No changes required for existing plugins.

Option 2: __all__ approach:
getattr(module, "__all__", module.__dict__)

  • Disallows multiple plugins outright a and, by extension, also prevents b.
  • Requires changes to existing plugins (add __all__ export)

Proposal: Maybe we can combine both approaches: use __module__ to preserve existing behavior for internal plugins, and also introduce __all__ to make plugin import behavior more explicit and well-defined going forward. Doing both here is an win in my opinion and will improve beets.

@semohr semohr added this to the 2.4.1 milestone Sep 21, 2025
@snejus
Copy link
Member

snejus commented Sep 21, 2025

also introduce all to make plugin import behavior more explicit and well-defined going forward

Two reasons why we should not do this:

  1. You specified the first one yourself

a. A single plugin namespace should not export more than one plugin class (bijective mapping).

Our plugin loading mechanism relies on finding a single plugin class definition (our docs make this pretty clear). Someone attempting to export multiple plugins from the same namespace is misusing the API, thus it's their responsibility to redesign their plugin, once they notice that only the first plugin is loaded.

  1. This is purely theoretical - we don't have a real world example to apply this to.

@semohr
Copy link
Contributor Author

semohr commented Sep 21, 2025

Our plugin loading mechanism relies on finding a single plugin class definition (our docs make this pretty clear). Someone attempting to export multiple plugins from the same namespace is misusing the API, thus it's their responsibility to redesign their plugin, once they notice that only the first plugin is loaded.

I just looked at the dev docs and I dont think we mention that, atleast for me it is not clear 🤷

A concrete example of the problem:

class Base(BeetsPlugin):
    pass

class RealPlugin(Mixin):
    pass

There is nothing preventing a developer from writing code like this. Depending on the order of class definitions, Base or RealPlugin will be loaded, silently. That’s confusing, unpredictable, and very easy to overlook in code review. Might even depend on the python version as dict ordering was changed if i remember correctly.

thus it's their responsibility to redesign their plugin

I disagree! We should have a friendly plugin architecture that allows devs of any level to contribute and write plugins. If a developer tries to write a plugin without fully understanding the underlying architecture, as is often the case, we can’t really blame them if the plugin fails to load or if they decide not to continue contributing. Personally, I would find that frustrating too.

I don't say we need to check __all__, just raising an error in this case will be good too. Just doing anything instead of letting it happen silently. Most importantly if this would have been enforced from the beginning we would have never had this issue in the first place. Or would have catched the issue during the refactor. I just want to make things more maintainable going forward and let us focus on other things by making this more robust.

@snejus
Copy link
Member

snejus commented Sep 22, 2025

I just looked at the dev docs and I dont think we mention that, atleast for me it is not clear 🤷

Feel free to clarify this in the docs then. Though, I don't think there's a need to do so - after all, it seems like no one has encountered this issue in the last 15 years.

I think this is a textbook case of YAGNI violation. And ultimately, the point of this PR is to restore the previous behaviour for all plugins, regardless of how many classes they provide.

@semohr
Copy link
Contributor Author

semohr commented Sep 22, 2025

Feel free to clarify this in the docs then. Though, I don't think there's a need to do so - after all, it seems like no one has encountered this issue in the last 15 years.

I will try once again: We could have prevented this in the first place i.e. the regression. It is not primarily about the external plugin developer for me. A simple assertion or a raise would have shown this issue early. It will just prevent people (also us) doing stupid things down the line. Also it aligns expectations with the actual implementation.

I think this is a textbook case of YAGNI violation.

This makes no sense in this context... We are not talking about a new feature here which we need to continuously support. It is just a tiny improvement. You are interpreting this wrong! Also, the 600 open issues paint a different picture. More robustness will help us in the long run.

And ultimately, the point of this PR is to restore the previous behaviour for all plugins

Introducing both fixes as proposed restores the behaviour.

, regardless of how many classes they provide.

Allowing multiple plugin classes per namespace would require some changes to the core. While possible it is not what beets internally expects and I don't think it should. This would be an actual example of YAGNI.

@snejus
Copy link
Member

snejus commented Sep 23, 2025

Allowing multiple plugin classes per namespace would require some changes to the core. While possible it is not what beets internally expects and I don't think it should. This would be an actual example of YAGNI.

Guarding against purely a theoretical possibility without a known example in the wild is also an example of YAGNI.

I don't think I can contribute more to this discussion: the fix I proposed ensures the previous behaviour for all plugins. On the other hand, your suggestion breaks plugins that define multiple plugin classes which makes it unsuitable. Are you happy to adjust this PR or shall I open another one? We do need to fix this.

@semohr
Copy link
Contributor Author

semohr commented Sep 23, 2025

The sad reality is, both proposed fixes could break some plugins. But I believe checking the namespace name has a bigger limitations, namely re-export a plugin from an external (primary) namespace. I’d expect that to keep working, as I'm working on a tidal plugin which uses that approach. On the other hand, exporting multiple plugins in the same namespace feels like something that should never be supported in the first place, and with the proposed solution, the developer actually sees an error instead of the issue being hidden.

The discussion is getting stuck on blocking rather than weighing the tradeoffs. There’s no perfect solution here, and I’ve acknowledged the limitations of both approaches. My view is that going forward we should explicitly enforce only one plugin per namespace, regardless of where it’s originally defined. That strikes me as the cleanest and most predictable solution.

I’ve really tried to find a compromise here so we could both be happy, but at this point I feel I need to stand my ground on this.

@snejus
Copy link
Member

snejus commented Sep 23, 2025

@wisp3rwind help us

@snejus
Copy link
Member

snejus commented Sep 23, 2025

See #6039 for the immediate fix. With users affected, I've prioritized restoring functionality now.

@semohr, your points on robustness are good. Let's tackle this in a separate PR for the next minor release.

@JOJ0
Copy link
Member

JOJ0 commented Sep 28, 2025

I disagree! We should have a friendly plugin architecture that allows devs of any level to contribute and write plugins. If a developer tries to write a plugin without fully understanding the underlying architecture, as is often the case, we can’t really blame them if the plugin fails to load or if they decide not to continue contributing. Personally, I would find that frustrating too.

I don't say we need to check __all__, just raising an error in this case will be good too. Just doing anything instead of letting it happen silently. Most importantly if this would have been enforced from the beginning we would have never had this issue in the first place. Or would have catched the issue during the refactor. I just want to make things more maintainable going forward and let us focus on other things by making this more robust.

Guys sometimes a compromise needs to be made in life. I do not see why this solution of @semohr's is not good. Clearly errorying and pointing to usage of __all__ is super clear and plugin dev friendly.

Even though there is not thousands of plugins that run into this it is not bad to clearly raise something to help developers that run into it in the future. This solution is not really much more code than that other solution over there: #6039

@wisp3rwind what is your opinion on this? It seems like we need tiebreakers here to be able to move on. Thanks in advance!

@wisp3rwind
Copy link
Member

So, I didn't follow this conversation closely and have only very quickly read through it, so there's definitely a chance that I missed a point or even completely misunderstood something. I don't have a very clear opinion, but a few thoughts:

While this issue hasn't showed up through beets existence before, I do have the impression that the previous implementation (where finding re-"exported" plugin classes in unrelated plugin modules was possible and just happened to work due to how plugin classes were stored in a dict) wasn't well thought out and probably worked rather by chance than conscious design. I think it makes sense to clean this up and have a better documented and more explicit way of detecting/declaring the intended plugin class.

For backwards compatibility, I think we should try to get as close to the old behavior as possible, without requiring changes to plugins. In that sense, Option 1, i.e. the

obj.__module__ == namespace.__path__ or obj.__module__.startswith(f"{namespace.__path__}.")

check (which is essentially #6039 if I didn't miss anything) seems sensible. From the above, it also appears that everybody would be fine with that?

Then, the remaining question is whether we should also add the additional features of Option 2,

  • (a) detect the ambiguous case where a plugin defines several candidate classes (e.g. base class + final plugin class) and produce an error in that case
  • (b) provide an additional way to disambiguate in that case (e.g. via __all__)

Regarding (a), relying on any implicit ordering to determine the class seems brittle at a first glance. It's probably not much of an issue in reality as pointed out above: First, if it happens, it's figured out once by plugin authors when testing their plugin and won't be seen by users. Second, because dict ordering is deterministic these days by preserving insertion order IIRC. Nevertheless, I'm in favor of checking for this, it just doesn't seem like a great "API" to rely on definition order in a plugin module 1. Giving a hint to developers here seems like a nice gesture without a big increase in complexity.
However, I think this should produce a warning initially (and load the first class in the dict), which we might escalate to an exception at some point.

Regarding (b), I'm less sure about the necessity, but I'm fine with doing this as well. It probably does not improve plugin development ergonomics by a huge margin (considering the beatport diff given above), but I do like that it is rather explicit, and __all__ does seem like an obvious way to declare the intended class which I could imagine people try even without checking our docs. (You could argue the reverse here, that it would be unexpected if __all__ didn't work, given that for standard imports, it does have the desired effect.) Looking at this PR, the complexity of supporting this is also rather low. (Although I'd be happy about a code comment explaining what's going in the various getattrs.)


So, in summary, I tend to say: Let's merge (something like) @snejus PR quickly2, to leave room for any discussion around how to do any further changes, but do take the latter seriously.
I didn't look at the docs, but it would be great if whatever we do, we make sure that the behavior and how to deal with it is explained clearly.

Footnotes

  1. Although that's generally a thing in Python where modules are executed in definition order upon import, which showed for example in type annotations before they started to be evaluated lazily.

  2. It does some additional refactoring, which I didn't check in detail, but at least the importlib conversion seems sensible. In fact, I had prototyped that as well in https://github.com/beetbox/beets/pull/5739, but I let that linger...

@JOJ0
Copy link
Member

JOJ0 commented Sep 29, 2025

Thanks @wisp3rwind!. I've just approved #6039 and want to ask if someone wants to find a way to remember the "making this future proof parts":

Then, the remaining question is whether we should also add the additional features of Option 2,

(a) detect the ambiguous case where a plugin defines several candidate classes (e.g. base class + final plugin class) and produce an error in that case
(b) provide an additional way to disambiguate in that case (e.g. via all)

Change this PR? Or open a new PR right away? Convert an issue out of @wisp3rwind's post? Not sure what's best.

And of course follow this advice :-)

I didn't look at the docs, but it would be great if whatever we do, we make sure that the behavior and how to deal with it is explained clearly

@wisp3rwind
Copy link
Member

Thanks @wisp3rwind!. I've just approved #6039 and want to ask if someone wants to find a way to remember the "making this future proof parts":

Maybe just continue in this PR, if @semohr is still interested in polishing it? I think it would be interesting to see what it looks like with some of the above suggestions for better backwards compatibility applied.

@semohr
Copy link
Contributor Author

semohr commented Sep 30, 2025

First, if it happens, it's figured out once by plugin authors when testing their plugin and won't be seen by users... Giving a hint to developers here seems like a nice gesture without a big increase in complexity.

Beyond helping plugin developers, it also gives us a way to verify the intended behavior. While I don’t expect the internal plugin import logic to change often, a recent change here (#5887) was the original cause of this issue. Having a check in place, one that we are actually able to test will be a improvement. For me this is quite significant portion of the fix and one if not the main reason for me to push this hard here.

Regarding (b), I'm less sure about the necessity, ... does seem like an obvious way to declare the intended class which I could imagine people try even without checking our docs.

I agree while it is not necessary, it seems like the obvious way how imports should be handled. As mentioned earlier I'm also fine with dropping this.

@semohr
Copy link
Contributor Author

semohr commented Sep 30, 2025

Maybe just continue in this PR, if @semohr is still interested in polishing it? I think it would be interesting to see what it looks like with some of the above suggestions for better backwards compatibility applied.

I just rebased.

However, I think this should produce a warning initially (and load the first class in the dict), which we might escalate to an exception at some point.

Added a deprecation warning for python 3.0.0. We can change it to an exception once we do a version bump.

@semohr semohr force-pushed the dupe_plugin_fix branch 2 times, most recently from c5d9917 to b45ce34 Compare September 30, 2025 21:52
@semohr semohr modified the milestones: 2.5.0, 2.6.0 Oct 11, 2025
@semohr semohr requested a review from snejus October 13, 2025 15:29
@semohr semohr added the core Pull requests that modify the beets core `beets` label Oct 13, 2025
@amogus07
Copy link
Contributor

As a plugin dev working on a real-world example of (a) (see the above-mentioned issue), I think it should be allowed to have the class that's intended to be registered by beets in plugin/__init__.py and the base class that inherits from (which itself inherits from BeetsPlugin or MetadataSourcePlugin) in plugin/base.py. Plugins imported in plugin.py or plugin/__init.py could still be supported but only as a fallback if there is no valid class in the module itself. Alternatively, adding the intended class to __all__ could be enforced for any imported plugin classes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Pull requests that modify the beets core `beets`

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugins loaded multiple times Plugin system broken on Windows/Python 3.13: beetsplug package missing init.py files, plugins not discovered

5 participants