Repository: sopel-irc/sopel

Issue:
Module members with attributes named interval, rule, etc. confused for decorated functions
Modules with 'interval' config option don't work, they throw this error:

TypeError: 'ValidatedAttribute' object is not iterable (file "/home/julen/projects/sopel/sopel/bot.py", line 177, in register)

If i change the config option name to something else they work properle

Tryed with this to discard any other problem:

interval.py
import sopel
from sopel.config.types import ValidatedAttribute, StaticSection

class IntervalSection(StaticSection):
    interval = ValidatedAttribute('interval', default='5')

def configure(config):
    config.define_section('test', IntervalSection)
    config.test.configure_setting(
        'interval',
        'interval to work'
    )

def setup(bot=None):
    if not bot:
        return
    bot.config.define_section('test', IntervalSection)



Discussion:
Having an interval attribute on anything callable in your module makes Sopel think it's a function it should call periodically; @interval basically just adds an interval attribute to a function. For now, you'll have to rename the attribute to something else (the actual setting in the config file can be the same, but the name of the attribute on the class has to be different).

Fixing this well is a bit difficult. I don't want to just add `and not isattribute(StaticSection)` to [the logic that finds those things](https://github.com/sopel-irc/sopel/blob/master/sopel/loader.py#L195-L214), because obviously this can still also happen to anything else in your module. (Honestly, I'm kind of surprised that this is the first time I've heard of it happening). It'll probably have to be reworking the decorators to use mangled attribute names, which would once and for all break backwards compatibility with the ancient versions, so there probably won't be a complete solution until at least 7.0.

Just a quick note here: this kind of issues arise in part because there is no way to tell Sopel that "this is a module", and that "this isn't a module": no meta-data, no "ModuleConfig" object, etc. nothing!

As said by @embolia, as it is, it's not easy to fix, and I'm not sure it's something doable for 7.x release.
But that's something I wish to work on if I've the time.
Milestone assigned so we don't lose track of this.
As noted in my writeup for #1931, even an innocent-looking global variable containing an innocent-looking stdlib object can trigger this.
When I wrote this in the doc for Sopel 7:

    Sopel identifies a callable as a rule when it has been decorated with any of
    these decorators from :mod:`sopel.plugin`

I didn't realize that I basically got the solution for this issue.

As a result, these decorators will add an attribute and set it to `True` to the decorated callable. Sopel won't collect objects without this attribute, and it has to be set to `True`.

So yeah, it'll be done for Sopel 7.1!

Resolving patch:
diff --git a/sopel/loader.py b/sopel/loader.py
--- a/sopel/loader.py
+++ b/sopel/loader.py
@@ -234,8 +234,11 @@ def clean_module(module, config):
     urls = []
     for obj in itervalues(vars(module)):
         if callable(obj):
+            is_sopel_callable = getattr(obj, '_sopel_callable', False) is True
             if getattr(obj, '__name__', None) == 'shutdown':
                 shutdowns.append(obj)
+            elif not is_sopel_callable:
+                continue
             elif is_triggerable(obj):
                 clean_callable(obj, config)
                 callables.append(obj)
diff --git a/sopel/plugin.py b/sopel/plugin.py
--- a/sopel/plugin.py
+++ b/sopel/plugin.py
@@ -147,6 +147,7 @@ def spam_every_5s(bot):
 
     """
     def add_attribute(function):
+        function._sopel_callable = True
         if not hasattr(function, "interval"):
             function.interval = []
         for arg in intervals:
@@ -201,6 +202,7 @@ def rule(*patterns):
 
     """
     def add_attribute(function):
+        function._sopel_callable = True
         if not hasattr(function, "rule"):
             function.rule = []
         for value in patterns:
@@ -255,6 +257,7 @@ def find(*patterns):
 
     """
     def add_attribute(function):
+        function._sopel_callable = True
         if not hasattr(function, "find_rules"):
             function.find_rules = []
         for value in patterns:
@@ -312,6 +315,7 @@ def search(*patterns):
 
     """
     def add_attribute(function):
+        function._sopel_callable = True
         if not hasattr(function, "search_rules"):
             function.search_rules = []
         for value in patterns:
@@ -441,6 +445,7 @@ def command(*command_list):
 
     """
     def add_attribute(function):
+        function._sopel_callable = True
         if not hasattr(function, "commands"):
             function.commands = []
         for command in command_list:
@@ -486,6 +491,7 @@ def nickname_command(*command_list):
 
     """
     def add_attribute(function):
+        function._sopel_callable = True
         if not hasattr(function, 'nickname_commands'):
             function.nickname_commands = []
         for cmd in command_list:
@@ -540,6 +546,7 @@ def action_command(*command_list):
 
     """
     def add_attribute(function):
+        function._sopel_callable = True
         if not hasattr(function, 'action_commands'):
             function.action_commands = []
         for cmd in command_list:
@@ -608,6 +615,7 @@ def event(*event_list):
 
     """
     def add_attribute(function):
+        function._sopel_callable = True
         if not hasattr(function, "event"):
             function.event = []
         for name in event_list:
@@ -632,6 +640,7 @@ def intent(*intent_list):
 
     """
     def add_attribute(function):
+        function._sopel_callable = True
         if not hasattr(function, "intents"):
             function.intents = []
         for name in intent_list:
@@ -945,6 +954,7 @@ def handle_example_bugs(bot, trigger):
 
     """
     def actual_decorator(function):
+        function._sopel_callable = True
         if not hasattr(function, 'url_regex'):
             function.url_regex = []
         for url_rule in url_rules:
@@ -995,6 +1005,7 @@ def my_url_handler(bot, trigger):
 
     """
     def decorator(function):
+        function._sopel_callable = True
         if not hasattr(function, 'url_lazy_loaders'):
             function.url_lazy_loaders = []
         function.url_lazy_loaders.extend(loaders)


Test patch:
diff --git a/test/test_loader.py b/test/test_loader.py
--- a/test/test_loader.py
+++ b/test/test_loader.py
@@ -63,6 +63,25 @@ def shutdown():
 def ignored():
     pass
 
+
+@sopel.module.rate(10)
+def ignored_rate():
+    pass
+
+
+class Ignored:
+    def __init__(self):
+        self.rule = [r'.*']
+
+    def __call__(self, bot, trigger):
+        pass
+
+ignored_obj = Ignored()
+
+def ignored_trickster():
+    pass
+
+ignored_trickster._sopel_callable = True
 """
 
 
@@ -185,6 +204,23 @@ def test_clean_module(testplugin, tmpconfig):
     assert test_mod.ignored not in jobs
     assert test_mod.ignored not in shutdowns
     assert test_mod.ignored not in urls
+    # @rate doesn't create a callable and is ignored
+    assert test_mod.ignored_rate not in callables
+    assert test_mod.ignored_rate not in jobs
+    assert test_mod.ignored_rate not in shutdowns
+    assert test_mod.ignored_rate not in urls
+    # object with a triggerable attribute are ignored by default
+    assert loader.is_triggerable(test_mod.ignored_obj)
+    assert test_mod.ignored_obj not in callables
+    assert test_mod.ignored_obj not in jobs
+    assert test_mod.ignored_obj not in shutdowns
+    assert test_mod.ignored_obj not in urls
+    # trickster function is ignored: it's still not a proper plugin callable
+    assert not loader.is_triggerable(test_mod.ignored_trickster)
+    assert test_mod.ignored_trickster not in callables
+    assert test_mod.ignored_trickster not in jobs
+    assert test_mod.ignored_trickster not in shutdowns
+    assert test_mod.ignored_trickster not in urls
 
 
 def test_clean_module_idempotency(testplugin, tmpconfig):