home · contact · privacy
Move fundamental message parsing code into msg_parse_expectations module.
authorChristian Heller <c.heller@plomlompom.de>
Wed, 3 Sep 2025 21:20:28 +0000 (23:20 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Wed, 3 Sep 2025 21:20:28 +0000 (23:20 +0200)
ircplom/client.py
ircplom/msg_parse_expectations.py

index 342b4eb63c0b3b9320cee547083bc0515f9172d6..509f8d3acf7a21c2a984c644c074397914fad4ee 100644 (file)
@@ -14,7 +14,7 @@ from ircplom.events import (
 from ircplom.irc_conn import (
     BaseIrcConnection, IrcConnAbortException, IrcMessage,
     ILLEGAL_NICK_CHARS, ILLEGAL_NICK_FIRSTCHARS, ISUPPORT_DEFAULTS, PORT_SSL)
-from ircplom.msg_parse_expectations import MsgTok, MSG_EXPECTATIONS
+from ircplom.msg_parse_expectations import MSG_EXPECTATIONS
 
 
 _NAMES_DESIRED_SERVER_CAPS = ('sasl',)
@@ -419,13 +419,23 @@ class _NickUserHost(NickUserHost):
         else:
             super().__setattr__(key, value)
 
+    @staticmethod
+    def possible_from(value: str) -> bool:
+        'If class instance could be parsed from value.'
+        toks = value.split('!')
+        if not len(toks) == 2:
+            return False
+        toks = toks[1].split('@')
+        if not len(toks) == 2:
+            return False
+        return True
+
     @classmethod
     def from_str(cls, value: str) -> Self:
         'Produce from string assumed to fit _!_@_ pattern.'
+        assert cls.possible_from(value)
         toks = value.split('!')
-        assert len(toks) == 2
         toks = toks[0:1] + toks[1].split('@')
-        assert len(toks) == 3
         return cls(*toks)
 
     @property
@@ -499,11 +509,17 @@ class _ClientDb(_UpdatingMixin, SharedClientDbFields):
                         if id_ not in to_keep]:
             del self.users[user_id]
 
-    @property
-    def illegal_nick_firstchars(self) -> str:
-        'Calculated from hardcoded constants and .isupport.'
-        return (ILLEGAL_NICK_CHARS + ILLEGAL_NICK_FIRSTCHARS
-                + self.isupport['CHANTYPES'] + self._get_membership_prefixes())
+    def is_nick(self, nick: str) -> bool:
+        'Tests name to match rules for nicknames.'
+        if len(nick) == 0:
+            return False
+        if nick[0] in (ILLEGAL_NICK_FIRSTCHARS
+                       + self.isupport['CHANTYPES']
+                       + self._get_membership_prefixes()):
+            return False
+        for c in [c for c in nick if c in ILLEGAL_NICK_CHARS]:
+            return False
+        return True
 
     def _get_membership_prefixes(self) -> str:
         'Registered possible membership nickname prefixes.'
@@ -678,95 +694,30 @@ class Client(ABC, ClientQueueMixin):
                 self._log(to_log, scope=log_target)
         self._log(msg.raw, scope=LogScope.RAW, out=True)
 
-    def _match_msg(self, msg: IrcMessage) -> dict[str, Any]:
-        'Test .source, .verb, .params.'
-        tok_type = str | _NickUserHost | tuple[str, ...]
-
-        def param_match(ex_tok: str | MsgTok, msg_tok: str | list[str]
-                        ) -> Optional[tok_type | tuple[tok_type, ...]]:
-            if isinstance(msg_tok, list):
-                to_return = []
-                for item in msg_tok:
-                    result = param_match(ex_tok, item)
-                    if not isinstance(result, tok_type):
-                        return None
-                    to_return += [result]
-                return tuple(to_return)
-            if isinstance(ex_tok, str):
-                return msg_tok if msg_tok == ex_tok else None
-            if ex_tok is MsgTok.NONE:
-                return msg_tok if msg_tok == '' else None
-            if ex_tok is MsgTok.SERVER:
-                return msg_tok if ('.' in msg_tok
-                                   and not set('@!') & set(msg_tok)) else None
-            if ex_tok is MsgTok.CHANNEL:
-                return msg_tok if self.db.is_chan_name(msg_tok) else None
-            if ex_tok is MsgTok.NICKNAME:
-                return (msg_tok
-                        if msg_tok[0] not in self.db.illegal_nick_firstchars
-                        else None)
-            if ex_tok is MsgTok.NICK_USER_HOST:
-                try:
-                    return _NickUserHost.from_str(msg_tok)
-                except AssertionError:
-                    return None
-            if ex_tok is MsgTok.LIST:
-                return tuple(msg_tok.split())
-            return msg_tok
-
-        def into_tasks_and_key(code: str, to_ret: dict[str, Any]) -> str:
-            tasks = to_ret['_tasks']
-            cmds_str, key = code.split(':', maxsplit=1) if code else ('', '')
-            for command in [t for t in cmds_str.split(',') if t]:
-                tasks[command] = tasks.get(command, []) + [key]
-            return key
-
-        for ex in [ex for ex in MSG_EXPECTATIONS if ex.verb == msg.verb]:
-            to_return: dict[str, Any] = {'verb': ex.verb, '_tasks': {}}
-            ex_tok_fields = tuple([ex.source] + list(ex.params))
-            msg_params: list[str | list[str]]
-            if ex.idx_into_list < 0:
-                msg_params = list(msg.params)
-            else:
-                idx_remainders = len(msg.params) + 1 - (len(ex.params)
-                                                        - ex.idx_into_list)
-                msg_params = list(msg.params[:ex.idx_into_list])\
-                    + [list(msg.params[ex.idx_into_list:idx_remainders])]\
-                    + list(msg.params[idx_remainders:])
-            msg_tok_fields = tuple([msg.source] + msg_params)
-            if ex.params and len(ex_tok_fields) != len(msg_tok_fields):
-                continue
-            passing = True
-            for idx, ex_tok in enumerate(ex_tok_fields):
-                ex_tok, code = ((ex_tok[0], ex_tok[1])
-                                if isinstance(ex_tok, tuple) else (ex_tok, ''))
-                key = into_tasks_and_key(code, to_return)
-                to_return[key] = param_match(ex_tok, msg_tok_fields[idx])
-                if to_return[key] is None:
-                    passing = False
-                    break
-            if passing:
-                for code in ex.bonus_tasks:
-                    into_tasks_and_key(code, to_return)
-                return to_return
-        return {}
-
     def handle_msg(self, msg: IrcMessage) -> None:
         'Log msg.raw, then process incoming msg into appropriate client steps.'
         self._log(msg.raw, scope=LogScope.RAW, out=False)
-        ret = self._match_msg(msg)
-        if 'verb' not in ret:
+        ret = {}
+        for ex in [ex for ex in MSG_EXPECTATIONS if ex.verb == msg.verb]:
+            result = ex.parse_msg(
+                    msg=msg,
+                    is_chan_name=self.db.is_chan_name,
+                    is_nick=self.db.is_nick,
+                    possible_nickuserhost=_NickUserHost.possible_from,
+                    into_nickuserhost=_NickUserHost.from_str)
+            if result is not None:
+                ret = result
+                break
+        if '_verb' not in ret:
             self._log(f'PLEASE IMPLEMENT HANDLER FOR: {msg.raw}')
             return
         for task, tok_names in ret['_tasks'].items():
-            task_verb, target_name = task.split('_', maxsplit=1)
-            if task_verb == 'set' and target_name == 'user':
+            if task.verb == 'set' and task.path == ('user',):
                 for tok_name in tok_names:
                     self.db.user_id(ret[tok_name])
                 continue
-            path_toks = target_name.split('.')
             node = self
-            for step in [t for t in path_toks if t]:
+            for step in task.path:
                 node = (node[ret[step] if step.isupper() else step]
                         if isinstance(node, Dict)
                         else getattr(node, step))
@@ -774,41 +725,41 @@ class Client(ABC, ClientQueueMixin):
                 # FIXME: alphabetical sorting of tok_names merely hack to parse
                 # TOPIC messages, to ensure any setattr_topic:what be processed
                 # before any setattr_topic:who, i.e. properly completing .topic
-                if task_verb == 'setattr':
+                if task.verb == 'setattr':
                     setattr(node, tok_name, ret[tok_name])
-                elif task_verb == 'do':
+                elif task.verb == 'do':
                     getattr(node, tok_name)()
-        if ret['verb'] == '005':   # RPL_ISUPPORT
+        if ret['_verb'] == '005':   # RPL_ISUPPORT
             for item in ret['isupport']:
                 if item[0] == '-':
                     del self.db.isupport[item[1:]]
                 else:
                     key, data = _Dict.key_val_from_eq_str(item)
                     self.db.isupport[key] = data
-        elif ret['verb'] == '353':  # RPL_NAMREPLY
+        elif ret['_verb'] == '353':  # RPL_NAMREPLY
             self.db.channels[ret['channel']].add_from_namreply(ret['names'])
-        elif ret['verb'] == '372':  # RPL_MOTD
+        elif ret['_verb'] == '372':  # RPL_MOTD
             self.db.motd.append(ret['line'])
-        elif ret['verb'] == '401':  # ERR_NOSUCHNICK
+        elif ret['_verb'] == '401':  # ERR_NOSUCHNICK
             self._log(f'{ret["target"]} not online', scope=LogScope.CHAT,
                       target=ret['target'], alert=True)
-        elif ret['verb'] == '432':  # ERR_ERRONEOUSNICKNAME
+        elif ret['_verb'] == '432':  # ERR_ERRONEOUSNICKNAME
             alert = 'nickname refused for bad format'
             if 'nick' not in ret:
                 alert += ', giving up'
                 self.close()
             self._log(alert, alert=True)
-        elif ret['verb'] == '433':  # ERR_NICKNAMEINUSE
+        elif ret['_verb'] == '433':  # ERR_NICKNAMEINUSE
             self._log('nickname already in use, trying increment', alert=True)
             self.send(IrcMessage(
                 'NICK', (_NickUserHost(nick=ret['used']).incremented,)))
-        elif ret['verb'] == 'AUTHENTICATE':
+        elif ret['_verb'] == 'AUTHENTICATE':
             auth = b64encode((self.db.nick_wanted + '\0'
                               + self.db.nick_wanted + '\0'
                               + self.db.password
                               ).encode('utf-8')).decode('utf-8')
             self.send(IrcMessage('AUTHENTICATE', (auth,)))
-        elif ret['verb'] == 'CAP':
+        elif ret['_verb'] == 'CAP':
             if (self.caps.process_msg(verb=ret['subverb'], items=ret['items'],
                                       complete='tbc' not in ret)
                     and 'sasl' in self.db.caps.keys()
@@ -818,15 +769,15 @@ class Client(ABC, ClientQueueMixin):
                     self.send(IrcMessage('AUTHENTICATE', ('PLAIN',)))
                 else:
                     self.caps.end_negotiation()
-        elif ret['verb'] == 'JOIN'\
+        elif ret['_verb'] == 'JOIN'\
                 and ret['joiner'].nick != self.db.users['me'].nick:
             self.db.channels[ret['channel']].append_nick(ret['joiner'])
-        elif ret['verb'] == 'NICK':
+        elif ret['_verb'] == 'NICK':
             user_id = self.db.user_id(ret['named'])
             self.db.users[user_id].nick = ret['nick']
             if user_id == 'me':
                 self.db.nick_wanted = ret['nick']
-        elif ret['verb'] in {'NOTICE', 'PRIVMSG'}:
+        elif ret['_verb'] in {'NOTICE', 'PRIVMSG'}:
             kw: dict[str, bool | str | LogScope] = {
                     'as_notice': msg.verb == 'NOTICE'}
             if 'sender' in ret:  # not just server message
@@ -834,16 +785,16 @@ class Client(ABC, ClientQueueMixin):
                        'target': (ret['sender'].nick if 'nick' in ret
                                   else ret['channel'])}
             self._log(ret['message'], out=False, **kw)
-        elif ret['verb'] == 'PART':
+        elif ret['_verb'] == 'PART':
             self.db.channels[ret['channel']].remove_nick(ret['parter'])
             if 'message' in ret:
                 self._log(f'{ret["parter"]} parts: {ret["message"]}',
                           LogScope.CHAT, target=ret['channel'])
             if ret['parter'] == self.db.users['me']:
                 del self.db.channels[ret['channel']]
-        elif ret['verb'] == 'PING':
+        elif ret['_verb'] == 'PING':
             self.send(IrcMessage(verb='PONG', params=(ret['reply'],)))
-        elif ret['verb'] == 'QUIT':
+        elif ret['_verb'] == 'QUIT':
             for ch_name, ch in self.db.chans_of_user(ret['quitter']).items():
                 ch.remove_nick(ret['quitter'])
                 self._log(f'{ret["quitter"]} quits: {ret["message"]}',
index 35c3606ab2c2b19d1c8c40bd3e29b8fa0691b1cc..93ad0bedfc73dc88e260727f956fb4faab2de973 100644 (file)
@@ -1,9 +1,10 @@
 'Structured expectations and processing hints for server messages.'
 from enum import Enum, auto
-from typing import NamedTuple
+from typing import Any, Callable, NamedTuple, Optional, Self
+from ircplom.irc_conn import IrcMessage
 
 
-class MsgTok(Enum):
+class _MsgTok(Enum):
     'Server message token classifications.'
     ANY = auto()
     CHANNEL = auto()
@@ -14,15 +15,120 @@ class MsgTok(Enum):
     NICK_USER_HOST = auto()
 
 
-_MsgTokGuide = str | MsgTok | tuple[str | MsgTok, str]
-
-
-class _MsgParseExpectation(NamedTuple):
-    verb: str
-    source: _MsgTokGuide
-    params: tuple[_MsgTokGuide, ...] = tuple()
-    idx_into_list: int = -1
-    bonus_tasks: tuple[str, ...] = tuple()
+_MsgTokGuide = str | _MsgTok | tuple[str | _MsgTok, str]
+
+
+class _Command:
+
+    def __init__(self, input_: str) -> None:
+        self.verb, path_str = input_.split('_', maxsplit=1)
+        self.path = tuple(path_str.split('.'))
+
+    def __str__(self) -> str:
+        return f'{self.verb}_{".".join(self.path)}'
+
+    def __hash__(self) -> int:
+        return hash(str(self))
+
+    def __eq__(self, other) -> bool:
+        return hash(self) == hash(other)
+
+
+class _MsgParseExpectation:
+
+    def __init__(self,
+                 verb: str,
+                 source: _MsgTokGuide,
+                 params: tuple[_MsgTokGuide, ...] = tuple(),
+                 idx_into_list: int = -1,
+                 bonus_tasks: tuple[str, ...] = tuple()
+                 ) -> None:
+
+        class _Code(NamedTuple):
+            title: str
+            commands: tuple[_Command, ...]
+
+            @classmethod
+            def from_(cls, input_: str) -> Self:
+                'Split by ":" into commands (further split by ","), title.'
+                toks = input_.split(':', maxsplit=1)
+                title = toks[1]
+                commands = [_Command(t) for t in toks[0].split(',') if t]
+                return cls(title, tuple(commands))
+
+        class _TokExpectation(NamedTuple):
+            type_: _MsgTok | str
+            code: Optional[_Code]
+
+            @classmethod
+            def from_(cls, value: _MsgTokGuide) -> Self:
+                'Standardize value into .type_, (potentially empty) code.'
+                type_, code = ((value[0], _Code.from_(value[1]))
+                               if isinstance(value, tuple)
+                               else (value, None))
+                return cls(type_, code)
+
+        self.verb = verb
+        self.source = _TokExpectation.from_(source)
+        self.params = tuple(_TokExpectation.from_(param) for param in params)
+        self.idx_into_list = idx_into_list
+        self.bonus_tasks = tuple(_Code.from_(item) for item in bonus_tasks)
+
+    def parse_msg(self,
+                  msg: IrcMessage,
+                  is_chan_name: Callable,
+                  is_nick: Callable,
+                  possible_nickuserhost: Callable,
+                  into_nickuserhost: Callable
+                  ) -> Optional[dict[str, Any]]:
+        'Try parsing msg into informative result dictionary, or None on fail.'
+        cmp_params: list[str | tuple[str, ...]]
+        if self.idx_into_list < 0:
+            cmp_params = list(msg.params)
+        else:
+            idx_after = len(msg.params) + 1 - (len(self.params)
+                                               - self.idx_into_list)
+            cmp_params = (list(msg.params[:self.idx_into_list]) +
+                          [msg.params[self.idx_into_list:idx_after]] +
+                          list(msg.params[idx_after:]))
+        cmp_fields = tuple([msg.source] + cmp_params)
+        ex_fields = tuple([self.source] + list(self.params))
+        if len(ex_fields) != len(cmp_fields):
+            return None
+        validators: dict[_MsgTok, Callable[[Any], bool]] = {
+            _MsgTok.NONE: lambda tok: tok == '',
+            _MsgTok.CHANNEL: is_chan_name,
+            _MsgTok.NICKNAME: is_nick,
+            _MsgTok.NICK_USER_HOST: possible_nickuserhost,
+            _MsgTok.SERVER: lambda tok: '.' in tok and not set('@!') & set(tok)
+        }
+        parsers: dict[_MsgTok, Callable[[Any], Any]] = {
+            _MsgTok.LIST: lambda tok: tuple(tok.split()),
+            _MsgTok.NICK_USER_HOST: into_nickuserhost
+        }
+        parsed: dict[str, str | tuple[str, ...]] = {}
+        singled_tasks: list[tuple[_Command, str]] = []
+        for ex_tok, cmp_tok in [(ex_tok, cmp_fields[idx])
+                                for idx, ex_tok in enumerate(ex_fields)]:
+            if (not isinstance(ex_tok.type_, str))\
+                    and ex_tok.type_ in validators\
+                    and (not validators[ex_tok.type_](cmp_tok)):
+                return None
+            if ex_tok.code:
+                parsed[ex_tok.code.title] = (
+                        cmp_tok if (isinstance(ex_tok.type_, str)
+                                    or ex_tok.type_ not in parsers)
+                        else parsers[ex_tok.type_](cmp_tok))
+                singled_tasks += [(cmd, ex_tok.code.title)
+                                  for cmd in ex_tok.code.commands]
+        for code in self.bonus_tasks:
+            singled_tasks += [(cmd, code.title) for cmd in code.commands]
+        tasks: dict[_Command, list[str]] = {}
+        for cmd, title in singled_tasks:
+            if cmd not in tasks:
+                tasks[cmd] = []
+            tasks[cmd] += [title]
+        return parsed | {'_verb': self.verb, '_tasks': tasks}
 
 
 MSG_EXPECTATIONS: list[_MsgParseExpectation] = [
@@ -31,419 +137,419 @@ MSG_EXPECTATIONS: list[_MsgParseExpectation] = [
 
     _MsgParseExpectation(
         '001',  # RPL_WELCOME
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '002',  # RPL_YOURHOST
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '003',  # RPL_CREATED
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '004',  # RPL_MYINFO
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY,
-         MsgTok.ANY,
-         MsgTok.ANY,
-         MsgTok.ANY,
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY,
+         _MsgTok.ANY,
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '250',  # RPL_STATSDLINE / RPL_STATSCONN
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '251',  # RPL_LUSERCLIENT
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '252',  # RPL_LUSEROP
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY,
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '253',  # RPL_LUSERUNKNOWN
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY,
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '254',  # RPL_LUSERCHANNELS
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY,
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '255',  # RPL_LUSERME
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '265',  # RPL_LOCALUSERS
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
     _MsgParseExpectation(
         '265',  # RPL_LOCALUSERS
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY,
-         MsgTok.ANY,
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '266',  # RPL_GLOBALUSERS
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
     _MsgParseExpectation(
         '266',  # RPL_GLOBALUSERS
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY,
-         MsgTok.ANY,
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
 
     _MsgParseExpectation(
         '375',  # RPL_MOTDSTART already implied by 1st 372
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY)),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
 
     # various login stuff
 
     _MsgParseExpectation(
         '005',  # RPL_ISUPPORT
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.ANY, ':isupport'),
-         MsgTok.ANY),  # comment
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, ':isupport'),
+         _MsgTok.ANY),  # comment
         idx_into_list=1),
 
     _MsgParseExpectation(
         '372',  # RPL_MOTD
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.ANY, ':line'))),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, ':line'))),
 
     _MsgParseExpectation(
         '376',  # RPL_ENDOFMOTD
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.ANY),  # comment
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY),  # comment
         bonus_tasks=('do_db.motd:complete',)),
 
     _MsgParseExpectation(
         '396',  # RPL_VISIBLEHOST
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.SERVER, 'setattr_db.users.me:host'),
-         MsgTok.ANY)),  # comment
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.SERVER, 'setattr_db.users.me:host'),
+         _MsgTok.ANY)),  # comment
 
     # SASL
 
     _MsgParseExpectation(
         '900',  # RPL_LOGGEDIN
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'),
-         (MsgTok.ANY, 'setattr_db:sasl_account'),
-         MsgTok.ANY)),  # comment
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'),
+         (_MsgTok.ANY, 'setattr_db:sasl_account'),
+         _MsgTok.ANY)),  # comment
 
     _MsgParseExpectation(
         '903',  # RPL_SASLSUCCESS
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.ANY, 'setattr_db:sasl_auth_state')),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db:sasl_auth_state')),
         bonus_tasks=('do_caps:end_negotiation',)),
 
     _MsgParseExpectation(
         '904',  # ERR_SASLFAIL
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.ANY, 'setattr_db:sasl_auth_state')),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db:sasl_auth_state')),
         bonus_tasks=('do_caps:end_negotiation',)),
 
     _MsgParseExpectation(
         'AUTHENTICATE',
-        MsgTok.NONE,
+        _MsgTok.NONE,
         ('+',)),
 
     # capability negotation
 
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          ('NEW', ':subverb'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
 
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          ('DEL', ':subverb'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
 
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
+        _MsgTok.SERVER,
         ('*',
          ('ACK', ':subverb'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          ('ACK', ':subverb'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
 
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
+        _MsgTok.SERVER,
         ('*',
          ('NAK', ':subverb'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          ('NAK', ':subverb'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
 
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
+        _MsgTok.SERVER,
         ('*',
          ('LS', ':subverb'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
+        _MsgTok.SERVER,
         ('*',
          ('LS', ':subverb'),
          ('*', ':tbc'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          ('LS', ':subverb'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          ('LS', ':subverb'),
          ('*', ':tbc'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
 
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
+        _MsgTok.SERVER,
         ('*',
          ('LIST', ':subverb'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
+        _MsgTok.SERVER,
         ('*',
          ('LIST', ':subverb'),
          ('*', ':tbc'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          ('LIST', ':subverb'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
     _MsgParseExpectation(
         'CAP',
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          ('LIST', ':subverb'),
          ('*', ':tbc'),
-         (MsgTok.LIST, ':items'))),
+         (_MsgTok.LIST, ':items'))),
 
     # nickname management
 
     _MsgParseExpectation(
         '432',  # ERR_ERRONEOUSNICKNAME
-        MsgTok.SERVER,
+        _MsgTok.SERVER,
         ('*',
-         MsgTok.NICKNAME,  # no need to re-use the bad one
-         MsgTok.ANY)),  # comment
+         _MsgTok.NICKNAME,  # no need to re-use the bad one
+         _MsgTok.ANY)),  # comment
     _MsgParseExpectation(
         '432',  # ERR_ERRONEOUSNICKNAME
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         MsgTok.NICKNAME,  # no need to re-use the bad one
-         MsgTok.ANY)),  # comment
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.NICKNAME,  # no need to re-use the bad one
+         _MsgTok.ANY)),  # comment
 
     _MsgParseExpectation(
         '433',  # ERR_NICKNAMEINUSE
-        MsgTok.SERVER,
+        _MsgTok.SERVER,
         ('*',
-         (MsgTok.NICKNAME, ':used'),
-         MsgTok.ANY)),  # comment
+         (_MsgTok.NICKNAME, ':used'),
+         _MsgTok.ANY)),  # comment
     _MsgParseExpectation(
         '433',  # ERR_NICKNAMEINUSE
-        MsgTok.SERVER,
-        (MsgTok.NICKNAME,  # we rather go for incrementation
-         (MsgTok.NICKNAME, ':used'),
-         MsgTok.ANY)),  # comment
+        _MsgTok.SERVER,
+        (_MsgTok.NICKNAME,  # we rather go for incrementation
+         (_MsgTok.NICKNAME, ':used'),
+         _MsgTok.ANY)),  # comment
 
     _MsgParseExpectation(
         'NICK',
-        (MsgTok.NICK_USER_HOST, ':named'),
-        ((MsgTok.NICKNAME, ':nick'),)),
+        (_MsgTok.NICK_USER_HOST, ':named'),
+        ((_MsgTok.NICKNAME, ':nick'),)),
 
     # joining/leaving
 
     _MsgParseExpectation(
         '332',  # RPL_TOPIC
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.CHANNEL, ':CHAN'),
-         (MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what'))),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.CHANNEL, ':CHAN'),
+         (_MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what'))),
 
     _MsgParseExpectation(
         '333',  # RPL_TOPICWHOTIME
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.CHANNEL, ':CHAN'),
-         (MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'),
-         (MsgTok.ANY, ':timestamp'))),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.CHANNEL, ':CHAN'),
+         (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'),
+         (_MsgTok.ANY, ':timestamp'))),
 
     _MsgParseExpectation(
         '353',  # RPL_NAMREPLY
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          '@',
-         (MsgTok.CHANNEL, ':channel'),
-         (MsgTok.LIST, ':names'))),
+         (_MsgTok.CHANNEL, ':channel'),
+         (_MsgTok.LIST, ':names'))),
     _MsgParseExpectation(
         '353',  # RPL_NAMREPLY
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
          '=',
-         (MsgTok.CHANNEL, ':channel'),
-         (MsgTok.LIST, ':names'))),
+         (_MsgTok.CHANNEL, ':channel'),
+         (_MsgTok.LIST, ':names'))),
 
     _MsgParseExpectation(
         '366',  # RPL_ENDOFNAMES
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.CHANNEL, ':CHAN'),
-         MsgTok.ANY),  # comment
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.CHANNEL, ':CHAN'),
+         _MsgTok.ANY),  # comment
         bonus_tasks=('do_db.channels.CHAN.user_ids:complete',)),
 
     _MsgParseExpectation(
         'JOIN',
-        (MsgTok.NICK_USER_HOST, ':joiner'),
-        ((MsgTok.CHANNEL, ':channel'),)),
+        (_MsgTok.NICK_USER_HOST, ':joiner'),
+        ((_MsgTok.CHANNEL, ':channel'),)),
 
     _MsgParseExpectation(
         'PART',
-        (MsgTok.NICK_USER_HOST, ':parter'),
-        ((MsgTok.CHANNEL, ':channel'),)),
+        (_MsgTok.NICK_USER_HOST, ':parter'),
+        ((_MsgTok.CHANNEL, ':channel'),)),
     _MsgParseExpectation(
         'PART',
-        (MsgTok.NICK_USER_HOST, ':parter'),
-        ((MsgTok.CHANNEL, ':channel'),
-         (MsgTok.ANY, ':message'))),
-
-    _MsgParseExpectation(
-        'TOPIC',
-        (MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'),
-        ((MsgTok.CHANNEL, ':CHAN'),
-         (MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what'))),
+        (_MsgTok.NICK_USER_HOST, ':parter'),
+        ((_MsgTok.CHANNEL, ':channel'),
+         (_MsgTok.ANY, ':message'))),
 
     # messaging
 
     _MsgParseExpectation(
         '401',  # ERR_NOSUCKNICK
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.NICKNAME, ':target'),
-         MsgTok.ANY)),  # comment
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.NICKNAME, ':target'),
+         _MsgTok.ANY)),  # comment
 
     _MsgParseExpectation(
         'NOTICE',
-        MsgTok.SERVER,
+        _MsgTok.SERVER,
         ('*',
-         (MsgTok.ANY, ':message'))),
+         (_MsgTok.ANY, ':message'))),
     _MsgParseExpectation(
         'NOTICE',
-        MsgTok.SERVER,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.ANY, ':message'))),
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, ':message'))),
     _MsgParseExpectation(
         'NOTICE',
-        (MsgTok.NICK_USER_HOST, 'set_user:sender'),
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.ANY, ':message'))),
+        (_MsgTok.NICK_USER_HOST, 'set_user:sender'),
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, ':message'))),
     _MsgParseExpectation(
         'NOTICE',
-        (MsgTok.NICK_USER_HOST, 'set_user:sender'),
-        ((MsgTok.CHANNEL, ':channel'),
-         (MsgTok.ANY, ':message'))),
+        (_MsgTok.NICK_USER_HOST, 'set_user:sender'),
+        ((_MsgTok.CHANNEL, ':channel'),
+         (_MsgTok.ANY, ':message'))),
 
     _MsgParseExpectation(
         'PRIVMSG',
-        (MsgTok.NICK_USER_HOST, 'set_user:sender'),
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.ANY, ':message'))),
+        (_MsgTok.NICK_USER_HOST, 'set_user:sender'),
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, ':message'))),
     _MsgParseExpectation(
         'PRIVMSG',
-        (MsgTok.NICK_USER_HOST, 'set_user:sender'),
-        ((MsgTok.CHANNEL, ':channel'),
-         (MsgTok.ANY, ':message'))),
+        (_MsgTok.NICK_USER_HOST, 'set_user:sender'),
+        ((_MsgTok.CHANNEL, ':channel'),
+         (_MsgTok.ANY, ':message'))),
 
     # misc.
 
     _MsgParseExpectation(
         'ERROR',
-        MsgTok.NONE,
-        ((MsgTok.ANY, 'setattr_db:connection_state'),),
+        _MsgTok.NONE,
+        ((_MsgTok.ANY, 'setattr_db:connection_state'),),
         bonus_tasks=('do_:close',)),
 
     _MsgParseExpectation(
         'MODE',
-        (MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'),
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.ANY, 'setattr_db:user_modes'))),
+        (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'),
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db:user_modes'))),
     _MsgParseExpectation(
         'MODE',
-        MsgTok.NICKNAME,
-        ((MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (MsgTok.ANY, 'setattr_db:user_modes'))),
+        _MsgTok.NICKNAME,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db:user_modes'))),
 
     _MsgParseExpectation(
         'PING',
-        MsgTok.NONE,
-        ((MsgTok.ANY, ':reply'),)),
+        _MsgTok.NONE,
+        ((_MsgTok.ANY, ':reply'),)),
+
+    _MsgParseExpectation(
+        'TOPIC',
+        (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'),
+        ((_MsgTok.CHANNEL, ':CHAN'),
+         (_MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what'))),
 
     _MsgParseExpectation(
         'QUIT',
-        (MsgTok.NICK_USER_HOST, ':quitter'),
-        ((MsgTok.ANY, ':message'),)),
+        (_MsgTok.NICK_USER_HOST, ':quitter'),
+        ((_MsgTok.ANY, ':message'),)),
 ]