Repository: gawel/irc3

Issue:
Set `${##}` to `##` in the config parser
It would be useful for `##foo` channels on Libera[^1].

```none
# This looks better...
autojoins =
  ${##}nitterbot-test

# ...than this.
autojoins =
  ${#}${#}nitterbot-test
```

[^1]: [libera.chat/chanreg](https://libera.chat/chanreg) recommends `##` for informal channels.

Hints from issue comments:

Solution patch:
diff --git a/irc3/utils.py b/irc3/utils.py
index 6e879ff..459bcab 100644
--- a/irc3/utils.py
+++ b/irc3/utils.py
@@ -185,17 +185,32 @@ class Config(dict):
 
 def parse_config_env(env=None):
     """return config set in env vars.
-    `IRC3_BOT_PASSWORD=value` become `[bot] password=value`"""
+    `IRC3_BOT_PASSWORD=value` becomes `[bot] password=value`.
+    `IRC3__BOT__SASL_PASSWORD=value` becomes `[bot] sasl_password=value`.
+    """
     value = {}
     if env is None:
         env = os.environ
+
     for k, v in env.items():
-        if k.startswith('IRC3_'):
+        prefix = 'IRC3__'
+        if k.startswith(prefix):
+            # This prefix allows setting keys with underscores.
+            # Double underscores '__' are replaced with periods.
+            splitted = k.removeprefix(prefix).lower().split('__')
+            key = splitted.pop()
+            section = '.'.join(splitted)
+            section = value.setdefault(section, {})
+            section[key] = v
+        elif k.startswith('IRC3_'):
+            # This prefix replaces single underscores with periods.
+            # Keys with underscores cannot be set.
             splitted = k.lower().split('_')
             key = splitted.pop()
             section = '.'.join(splitted[1:])
             section = value.setdefault(section, {})
             section[key] = v
+
     return value
 
 
@@ -206,6 +221,7 @@ def parse_config(main_section, *filenames, **kwargs):
     here = os.path.dirname(filename)
     defaults = dict(here=here, hash='#')
     defaults['#'] = '#'
+    defaults['##'] = '##'
 
     config = configparser.ConfigParser(
         defaults, allow_no_value=False,


Test patch:
diff --git a/tests/test_cron.py b/tests/test_cron.py
index 8942e44..5dac2ea 100644
--- a/tests/test_cron.py
+++ b/tests/test_cron.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+import pytest
 from irc3.testing import BotTestCase
 from irc3.testing import MagicMock
 from irc3.plugins import cron
@@ -10,12 +11,12 @@ class MyCron:
     def __init__(self, bot):
         self.bot = bot
 
-    @cron.cron('* * * * * *')
+    @cron.cron('* * * * *')
     def method(self):
         return self.bot
 
 
-@cron.cron('* * * * * *')
+@cron.cron('* * * * *')
 async def function(bot):
     return bot
 
@@ -74,5 +75,11 @@ class TestCron(BotTestCase):
             asyncio.ensure_future(
                 c.next(), loop=loop).add_done_callback(complete)
 
-        loop.run_until_complete(f)
+        try:
+            loop.run_until_complete(asyncio.wait_for(f, timeout=61))
+        except asyncio.TimeoutError:
+            pytest.fail(
+                "Test timed out waiting for cron jobs. "
+                f"Results: {results}"
+            )
         assert results == [bot, bot]
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 52c4bff..c4daa60 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -16,8 +16,16 @@ autojoins =
     ${hash}irc3
     ${hash}${hash}irc3
     ${#}irc3
+    ${#}${#}irc3
+    ${##}irc3
 ''')
-    assert config['autojoins'] == ['#irc3', '##irc3', '#irc3']
+    assert config['autojoins'] == [
+        '#irc3',
+        '##irc3',
+        '#irc3',
+        '##irc3',
+        '##irc3',
+    ]
 
 
 def test_config_env():
@@ -76,11 +84,19 @@ class TestConfig(TestCase):
         value = parse_config_env({
             'IRC3_BOT_NICKNAME': 'env_nickname',
             'IRC3_BOT_PASSWORD': 'env_password',
+
+            'IRC3__BOT__SASL_PASSWORD': 'hunter2',
+            'IRC3__IRC3__PLUGINS__FOO__BAR_BAZ': 'qux',
         })
         self.assertEqual(value, {
             'bot': {
                 'nickname': 'env_nickname',
                 'password': 'env_password',
+
+                'sasl_password': 'hunter2',
+            },
+            'irc3.plugins.foo': {
+                'bar_baz': 'qux',
             },
         })