Repository: sopel-irc/sopel

Issue:
Unexpected error (get) from .announce
<!-- Before reporting a bug, please search both open *and closed* issues to
see if it has already been reported. If you can, try to reproduce the problem
on an unmodified copy of the `master` branch first, as sometimes bugs are found
and fixed without a report. If the problem is unreported and persists in
`master`, please help us fix it quickly by filling out as much of this
information as you can. Thanks! -->

### Description
.announce fatals with unexpected error (get)

### Reproduction steps
1. Upgrade to latest master
2. Connect to server, join a few channels
3. Use .announce
4. Check your logs

### Expected behavior
No fatals, it works 

### Logs
```
[2021-03-14 16:02:42,710] sopel.bot            ERROR    - Unexpected error (get) from RhinosF1 at 2021-03-14 16:02:42.710332. Message was: .announce this is a test --rf1
Traceback (most recent call last):
  File "/srv/sopelbots/devvenv/lib/python3.7/site-packages/sopel/bot.py", line 698, in call_rule
    rule.execute(sopel, trigger)
  File "/srv/sopelbots/devvenv/lib/python3.7/site-packages/sopel/plugins/rules.py", line 996, in execute
    exit_code = self._handler(bot, trigger)
  File "/srv/sopelbots/devvenv/lib/python3.7/site-packages/sopel/plugin.py", line 914, in guarded
    return function(bot, trigger, *args, **kwargs)
  File "/srv/sopelbots/devvenv/lib/python3.7/site-packages/sopel/modules/announce.py", line 37, in announce
    size = bot.isupport.get('TARGMAX', {}).get('PRIVMSG', 1)
  File "/srv/sopelbots/devvenv/lib/python3.7/site-packages/sopel/irc/isupport.py", line 192, in __getattr__
    raise AttributeError(name)
AttributeError: get
```

### Environment
- Sopel `.version`: latest master
- Sopel installed via: build our own wheel using build & then pip install
- Python version: 3.7.3
- Operating system: Debian Buster
- IRCd `/version`: ircd-seven-1.1.9(20191023-4b033a7b9221,charybdis-3.4-dev). egan.freenode.net
- Relevant plugins: announce

### Notes
Anything else we should know?



Discussion:
Instead of `bot.isupport.get` to check if a config value is available, an `if hasattr(bot.isupport, '...')` block must be used. That's a fairly easy fix.
@Exirel Is there a good reason not to give `bot.isupport` a `get()` method? It already kind of pretends to be a dict via supporting subscripting and I think the `in` operator. Might as well ape that part too.
Something something reason. 🙃 

I don't have a better answer right now. Might have a look at this.

Resolving patch:
diff --git a/sopel/irc/isupport.py b/sopel/irc/isupport.py
--- a/sopel/irc/isupport.py
+++ b/sopel/irc/isupport.py
@@ -123,7 +123,7 @@ def _parse_prefix(value):
     'SAFELIST': _no_value,
     'SILENCE': _optional(int),
     'STATUSMSG': _optional(tuple),
-    'TARGMAX': _optional(_map_items(int)),
+    'TARGMAX': _optional(_map_items(int), default=tuple()),
     'TOPICLEN': int,
     'USERLEN': int,
 }
@@ -204,6 +204,16 @@ def __setattr__(self, name, value):
         elif name not in self.__dict__:
             raise AttributeError('Unknown attribute')
 
+    def get(self, name, default=None):
+        """Retrieve value for the feature ``name``.
+
+        :param str name: feature to retrieve
+        :param default: default value if the feature is not advertised
+                        (defaults to ``None``)
+        :return: the value for that feature, if advertised, or ``default``
+        """
+        return self[name] if name in self else default
+
     def apply(self, **kwargs):
         """Build a new instance of :class:`ISupport`.
 
@@ -361,11 +371,16 @@ def TARGMAX(self):
                 'PRIVMSG': 3,
                 'WHOIS': 1,
             }
+            >>> isupport['TARGMAX']  # internal representation
+            (('JOIN', None), ('PRIVMSG', 3), ('WHOIS', 1))
 
         This attribute is not available if the server does not provide the
         right information, and accessing it will raise an
         :exc:`AttributeError`.
 
+        The internal representation of ``TARGMAX`` is a tuple of 2-value
+        tuples as seen above.
+
         .. seealso::
 
             https://modern.ircdocs.horse/#targmax-parameter
@@ -374,9 +389,5 @@ def TARGMAX(self):
         if 'TARGMAX' not in self:
             raise AttributeError('TARGMAX')
 
-        targmax = self['TARGMAX']
-
-        if targmax is None:
-            return {}
-
+        # always return a dict if None or empty tuple
         return dict(self['TARGMAX'] or [])
diff --git a/sopel/modules/announce.py b/sopel/modules/announce.py
--- a/sopel/modules/announce.py
+++ b/sopel/modules/announce.py
@@ -34,8 +34,14 @@ def announce(bot, trigger):
         bot.reply('Announce what? I need a message to say.')
         return
 
-    size = bot.isupport.get('TARGMAX', {}).get('PRIVMSG', 1)
+    size = 1
+    try:
+        size = bot.isupport.TARGMAX.get('PRIVMSG', size)
+    except AttributeError:
+        pass
+
     channels = _chunks(bot.channels.keys(), size)
     for cgroup in channels:
         bot.say(trigger.group(2), ','.join(cgroup))
+
     bot.reply('Announce complete.')


Test patch:
diff --git a/test/irc/test_irc_isupport.py b/test/irc/test_irc_isupport.py
--- a/test/irc/test_irc_isupport.py
+++ b/test/irc/test_irc_isupport.py
@@ -76,6 +76,22 @@ def test_isupport_removed_parameter():
     assert '-AWAYLEN' not in instance
 
 
+def test_isupport_get():
+    """Test parameter access through the ``get`` method."""
+    instance = isupport.ISupport(AWAYLEN=50)
+
+    assert instance.get('AWAYLEN') == 50
+    assert instance.get('AWAYLEN', 20) == 50, (
+        'Default must be ignored if name exists')
+    assert instance.get('AWAYLEN', default=10) == 50, (
+        'Default must be ignored if name exists')
+    assert instance.get('UNKNOWN') is None
+    assert instance.get('UNKNOWN', 20) == 20, (
+        'Default must be used if name does not exist')
+    assert instance.get('UNKNOWN', default=10) == 10, (
+        'Default must be used if name does not exist')
+
+
 def test_isupport_getitem():
     """Test basic parameter access."""
     instance = isupport.ISupport(AWAYLEN=50)
@@ -492,6 +508,13 @@ def test_parse_parameter_targmax():
     assert value == (('PRIVMSG', 3),)
 
 
+def test_parse_parameter_targmax_optional():
+    key, value = isupport.parse_parameter('TARGMAX=')
+
+    assert key == 'TARGMAX'
+    assert value == tuple()
+
+
 def test_parse_parameter_targmax_many():
     key, value = isupport.parse_parameter('TARGMAX=PRIVMSG:3,WHOIS:1')