Repository: sopel-irc/sopel

Issue:
announce error on python3
<!-- 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 results in an error on python 3

### Reproduction steps
1. Setup a instance on python3 (specifically I got the error on v3.7)
2. Try to use .announce
3. there will be a error

### Expected behavior
Works without errors.

### Logs
```
If applicable, add logs to help us figure out what's happening. Raw logs are
super helpful! Logs are usually found in ~/.sopel/logs, depending on your
configuration.
```
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]: [2021-07-22 17:04:51,684] sopel.bot            ERROR    - Unexpected error ('dict_keys' object is not subscriptable) from MacFan4000 at 2
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]: Traceback (most recent call last):
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]:   File "/srv/sopelbots/prodvenv/lib/python3.7/site-packages/sopel/bot.py", line 757, in call_rule
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]:     rule.execute(sopel, trigger)
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]:   File "/srv/sopelbots/prodvenv/lib/python3.7/site-packages/sopel/plugins/rules.py", line 1057, in execute
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]:     exit_code = self._handler(bot, trigger)
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]:   File "/srv/sopelbots/prodvenv/lib/python3.7/site-packages/sopel/plugin.py", line 1071, in guarded
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]:     return function(bot, trigger, *args, **kwargs)
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]:   File "/srv/sopelbots/prodvenv/lib/python3.7/site-packages/sopel/modules/announce.py", line 44, in announce
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]:     for cgroup in channels:
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]:   File "/srv/sopelbots/prodvenv/lib/python3.7/site-packages/sopel/modules/announce.py", line 24, in _chunks
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]:     yield items[delim:delim + size]
Jul 22 17:04:51 bots1.mirahezebots.org sopel[31484]: TypeError: 'dict_keys' object is not subscriptable
### Environment
- Sopel `.version`: [e.g. 7.0.0 or d416e19] 7.1.2
- Sopel installed via: [apt, pip, `setup.py install`, source, ?] pip
- Python version: [e.g. 3.6.9] 3.7
- Operating system: [e.g. Debian 10] Debian Buster
- IRCd `/version`: [e.g. InspIRCd 3.0.1] Libera Chat
- Relevant plugins: [adminchannel, weather, custom\_thing.py, ?] announce

### Notes
I believe https://github.com/sopel-irc/sopel/commit/b7b6b46a84e29e26a6a6b921debf57735661a4c0#diff-a9aa50736c17c299dac1ad9cb5ea1b835fb638c91bbd8c547990ffd9d67daa74 broke it due to .keys() not working the same way on python3 as it does on python2.



Discussion:
Didn't we report this before
That was #2040 
> Didn't we report this before

didn't realize

> That was #2040

that's not the same error.


Resolving patch:
diff --git a/sopel/modules/announce.py b/sopel/modules/announce.py
--- a/sopel/modules/announce.py
+++ b/sopel/modules/announce.py
@@ -8,6 +8,8 @@
 """
 from __future__ import generator_stop
 
+import itertools
+
 from sopel import plugin
 
 
@@ -17,10 +19,18 @@ def _chunks(items, size):
     :param items: the collection of items to chunk
     :type items: :term:`iterable`
     :param int size: the size of each chunk
+    :return: a :term:`generator` of chunks
+    :rtype: :term:`generator` of :class:`tuple`
     """
-    # from https://stackoverflow.com/a/312464/5991 with modified names for readability
-    for delim in range(0, len(items), size):
-        yield items[delim:delim + size]
+    # This approach is safer than slicing with non-subscriptable types,
+    # for example `dict_keys` objects
+    iterator = iter(items)
+    # TODO: Simplify to assignment expression (`while cond := expr`)
+    # when dropping Python 3.7
+    chunk = tuple(itertools.islice(iterator, size))
+    while chunk:
+        yield chunk
+        chunk = tuple(itertools.islice(iterator, size))
 
 
 @plugin.command('announce')


Test patch:
diff --git a/test/modules/test_modules_announce.py b/test/modules/test_modules_announce.py
new file mode 100644
--- /dev/null
+++ b/test/modules/test_modules_announce.py
@@ -0,0 +1,32 @@
+"""Tests for Sopel's ``announce`` plugin"""
+from __future__ import generator_stop
+
+from sopel.modules import announce
+
+
+def test_chunks():
+    """Test the `_chunks` helper for functionality and compatibility."""
+    # list input
+    items = ['list', 'of', 'items', 'to', 'chunk']
+    r = list(announce._chunks(items, 2))
+
+    assert len(r) == 3
+    assert r[0] == tuple(items[:2])
+    assert r[1] == tuple(items[2:4])
+    assert r[2] == tuple(items[4:])
+
+    # tuple input
+    items = ('tuple', 'of', 'items')
+    r = list(announce._chunks(items, 3))
+
+    assert len(r) == 1
+    assert r[0] == items
+
+    # dict keys input
+    keys = {'one': True, 'two': True, 'three': True}.keys()
+    items = list(keys)
+    r = list(announce._chunks(keys, 1))
+
+    assert len(r) == 3
+    for idx in range(len(items)):
+        assert r[idx] == (items[idx],)