Repository: sopel-irc/sopel

Issue:
Per-channel configuration doesn't work as expected/documented
### Description
It is not possible (any longer?) to use just the plugin name in per-channel configuration.

### Reproduction steps
1. Set up a per-channel configuration as documented, for example:
   ```
   [#channel]
   disable_plugins = emoticons
   disable_commands = {"url": ["title_auto"]}
   ```
2. Start bot, and make sure it is in `#channel`
3. Nothing in `#channel` is any different from before.

### Expected behavior
Per the intended functionality of this feature, and according to the website documentation, this configuration should prevent the use of all `emoticons` commands and block automatic link title fetching in the `#channel` channel.

### Environment
- Sopel `.version`: 7.0.1.dev0
- Sopel installed via: current `7.0.x` branch
- Python version: 3.6.9
- Operating system: Ubuntu 18.04 (WSL)
- IRCd `/version`: n/a
- Relevant plugins: n/a; this is a core issue

### Notes
The problem stems from use of `func.__module__` in the code that applies these restrictions: https://github.com/sopel-irc/sopel/blob/c705f450e56b9ddded10226c640a607249e33710/sopel/bot.py#L567-L587 In the past, I believe that `func.__module__` used to be just `pluginname` for plugin callables, but that is clearly no longer the case. Now, `func.__module__` for a core plugin like `url` is the full module path, `sopel.modules.url`. (It probably changed with #1479 and associated work, but I haven't verified that with a `git bisect` or anything. Circumstantially, #1235 implemented this long before #1479 altered the plugin mechanics, and multiple reports confirm that this *used to* work as documented.)

### Initial implementation ideas
The immediately obvious solution is to add an attribute to each plugin callable during loading, containing the plugin name as expected by this feature's code. Presumably, being able to tell which plugin a callable came from would be useful for future features too.

Alternatively, the bot's internal tracking of loaded plugins could include a way to translate module names into plugin names. I'm partial to the first solution because retrieving an attribute of the function currently being processed is probably cheaper from a performance perspective, and logically clearer than performing a lookup on some (probably) dict stored in the bot's global state.

But either idea would work for the current version. I really don't want to wait to fix this until @Exirel's very admirable goal of refactoring plugins from "bunch of callables with attributes" to "actual Plugin object with state & behavior" happens in 8.0 or 9.0. 😅


Discussion:
> In the past, I believe that `func.__module__` used to be just `pluginname` for plugin callables, but that is clearly no longer the case. Now, `func.__module__` for a core plugin like `url` is the full module path, `sopel.modules.url`.

Sadly, this is not true. Before I replied, I read both PR #1479 and #1235, and I've to say that the bug, as described here, is actually what was implemented in the first place in #1235.

The reason is: that's how Python works. More general information about that in the [Python documentation](https://docs.python.org/3.6/reference/datamodel.html). Here is an example, taken from Sopel's source code:

```
>>> from sopel.modules import url
>>> url.__name__
'sopel.modules.url'
>>> url.title_auto.__module__
'sopel.modules.url'
```

What the code does is it looks to the function's `__module__` attribute, which is set by Python, not by Sopel. It works fine for plugins defined in the `~/.sopel/plugins/` dir, because their plugin name **is** their Python module name, but it's not the case for these:

* `sopel.modules.url`: name is "url", `__module__` is `sopel.modules.url`
* `sopel_modules.url`: name is "url", `__module__` is `sopel_modules.url`
* `url = my.entrypoint.plugin`: name is "url", `__module__` is `my.entrypoint.plugin`

Same plugin name, not the same `__module__`, and the plugin's name is **not even derived** from the Python module for entrypoint!

I'd argue that this feature requires a complete rework, and that your solution of adding a `plugin_name` to each `func` is probably the best one. I'd suggest to implement that not in `clean_callable`, but in the Plugin handler's `register` method.
Note that if we consider that, prior to #1479, this worked for both `~/.sopel/modules/` plugins and core plugins (because they were loaded like they were in a folder and not as Python sub-module), we also have to acknowledged that this never worked for `sopel_modules.*` plugins.

And the lack of tests didn't help. :(
> Note that if we consider that, prior to #1479, this worked for both `~/.sopel/modules/` plugins and core plugins (because they were loaded like they were in a folder and not as Python sub-module), we also have to acknowledged that this never worked for `sopel_modules.*` plugins.

Splitting hairs, I was half-right about it working before #1479 because it made core plugins behave differently from user plugins. But that doesn't help fix the bug. 😛

Per discussion here, in #1840, and on IRC, I'll work on the `callbl.plugin_name` solution later today. (Assuming that I don't have an unexpected run-in with Real Life™, that is. 😁)

Resolving patch:
diff --git a/sopel/bot.py b/sopel/bot.py
--- a/sopel/bot.py
+++ b/sopel/bot.py
@@ -574,16 +574,28 @@ def call(self, func, sopel, trigger):
 
                 # if "*" is used, we are disabling all plugins on provided channel
                 if '*' in disabled_plugins:
+                    LOGGER.debug(
+                        "All plugins disabled in %s; skipping execution of %s.%s",
+                        trigger.sender, func.plugin_name, func.__name__
+                    )
                     return
-                if func.__module__ in disabled_plugins:
+                if func.plugin_name in disabled_plugins:
+                    LOGGER.debug(
+                        "Plugin %s is disabled in %s; skipping execution of %s",
+                        func.plugin_name, trigger.sender, func.__name__
+                    )
                     return
 
             # disable chosen methods from plugins
             if 'disable_commands' in channel_config:
                 disabled_commands = literal_eval(channel_config.disable_commands)
 
-                if func.__module__ in disabled_commands:
-                    if func.__name__ in disabled_commands[func.__module__]:
+                if func.plugin_name in disabled_commands:
+                    if func.__name__ in disabled_commands[func.plugin_name]:
+                        LOGGER.debug(
+                            "Skipping execution of %s.%s in %s: disabled_commands matched",
+                            func.plugin_name, func.__name__, trigger.sender
+                        )
                         return
 
         try:
diff --git a/sopel/plugins/handlers.py b/sopel/plugins/handlers.py
--- a/sopel/plugins/handlers.py
+++ b/sopel/plugins/handlers.py
@@ -36,6 +36,7 @@
 import inspect
 import imp
 import importlib
+import itertools
 import os
 
 from sopel import loader
@@ -259,6 +260,10 @@ def has_setup(self):
 
     def register(self, bot):
         relevant_parts = loader.clean_module(self._module, bot.config)
+        for part in itertools.chain(*relevant_parts):
+            # annotate all callables in relevant_parts with `plugin_name`
+            # attribute to make per-channel config work; see #1839
+            setattr(part, 'plugin_name', self.name)
         bot.add_plugin(self, *relevant_parts)
 
     def unregister(self, bot):


Test patch: