diff --git a/docs/source/irc.rst b/docs/source/irc.rst index 572bfb359e..b3a130e672 100644 --- a/docs/source/irc.rst +++ b/docs/source/irc.rst @@ -38,6 +38,13 @@ Backends :show-inheritance: +Mode Messages +============= + +.. automodule:: sopel.irc.modes + :members: + + ISUPPORT ======== diff --git a/sopel/irc/__init__.py b/sopel/irc/__init__.py index 2fc3cbfd9e..9ce02155ce 100644 --- a/sopel/irc/__init__.py +++ b/sopel/irc/__init__.py @@ -36,7 +36,7 @@ from .utils import CapReq, safe -__all__ = ['abstract_backends', 'backends', 'utils'] +__all__ = ['abstract_backends', 'backends', 'modes', 'utils'] LOGGER = logging.getLogger(__name__) diff --git a/sopel/irc/modes.py b/sopel/irc/modes.py new file mode 100755 index 0000000000..8d9c38ec19 --- /dev/null +++ b/sopel/irc/modes.py @@ -0,0 +1,234 @@ +"""Mode management for IRC channels. + +.. seealso:: + + https://modern.ircdocs.horse/#mode-message + +.. versionadded:: 8.0 +""" +from __future__ import generator_stop + +from collections import namedtuple +import logging +from typing import Dict, Generator, List, Optional, Set, Tuple + + +LOGGER = logging.getLogger(__name__) + +PARAM_ALWAYS = 'always' +PARAM_ADDED = 'added' +PARAM_REMOVED = 'removed' +PARAM_NEVER = 'never' +DEFAULT_MODETYPE_PARAM_CONFIG = { + 'A': PARAM_ALWAYS, + 'B': PARAM_ALWAYS, + 'C': PARAM_ADDED, + 'D': PARAM_NEVER, +} +"""Default parameter requirements for mode types.""" + + +class ModeException(Exception): + """Base exception class for mode management.""" + pass + + +class ModeTypeUnknown(ModeException): + """Exception when a mode's type is unknown or cannot be determined.""" + def __init__(self, mode) -> None: + super().__init__('Unknown type for mode %s' % mode) + + +class ModeTypeImproperlyConfigured(ModeException): + """Exception when the mode's type management is not configured.""" + def __init__(self, mode: str, letter: str) -> None: + message = 'Type {mode} for mode {letter} is not properly configured.' + super().__init__(message.format(mode=mode, letter=letter)) + + +def _modes_is_added(modestring: str + ) -> Generator[Tuple[str, bool], None, None]: + is_added = True + for char in modestring: + if char in '+-': + is_added = char == '+' + continue + yield (char, is_added) + + +ModeString = namedtuple('ModeString', [ + 'modes', + 'privileges', + 'ignored_modes', + 'leftover_params', +]) + + +class ModeMessage: + """Modestring parser for IRC's ``MODE`` messages for channel modes.""" + PRIVILEGES: Set[str] = { + "v", # VOICE + "h", # HALFOP + "o", # OP + "a", # ADMIN + "q", # OWNER + "y", # OPER + "Y", # OPER + } + """Set of user privileges used by default.""" + + def __init__( + self, + chanmodes: Dict[str, Tuple[str, ...]], + type_params: Dict[str, str] = DEFAULT_MODETYPE_PARAM_CONFIG, + privileges: Set[str] = PRIVILEGES, + ) -> None: + self.chanmodes: Dict[str, Tuple[str, ...]] = chanmodes + """Map of mode types (``str``) with their list of modes (``tuple``). + + This map should come from ``ISUPPORT``, usually through + :attr:`bot.isupport.CHANMODES `. + """ + self.type_params = type_params + """Map of mode types (``str``) with their param requirements. + + This map default to :data:`DEFAULT_MODETYPE_PARAM_CONFIG`. + """ + self.privileges = privileges + """Set of valid user privileges. + + This set should come from ``ISUPPORT``, usually through + :attr:`bot.isupport.PREFIX `. + + If a server doesn't advertise its prefixes for user privileges, + :attr:`PRIVILEGES` can be used as a default value. + """ + + def get_mode_type(self, mode: str) -> str: + """Retrieve the type of ``mode``. + + :raise ModeTypeUnknown: if the mode's type cannont be determined + :return: the mode's type as defined by :attr:`chanmodes` + + :: + + >>> mm = ModeMessage({'A': tuple('beI'), 'B': tuple('k')}, {}) + >>> mm.get_mode_type('b') + 'A' + >>> mm.get_mode_type('k') + 'B' + + This method will raise a :exc:`ModeTypeUnknown` if the mode is unknown, + including the case where ``mode`` is actually a user privilege. + (such as ``v``) + """ + for letter, modes in self.chanmodes.items(): + if mode in modes: + return letter + raise ModeTypeUnknown(mode) + + def get_mode_info( + self, + mode: str, + is_added: bool + ) -> Tuple[Optional[str], bool, bool]: + """Retrieve ``mode``'s information when added or removed. + + :raise ModeTypeUnknown: when the mode's type is unknown and isn't a + user privilege + :raise ModeTypeImproperlyConfigured: when the mode's type is known but + there is no information for + parameters (if and when they are + required by the mode) + :return: a tuple with three values: the mode type, if it requires a + parameter, and if it's a channel mode or a user privilege + + :: + + >>> chanmodes = {'A': tuple('beI'), 'B': tuple('k')} + >>> t_params = {'A': PARAM_ALWAYS, 'B': PARAM_ADDED} + >>> mm = ModeMessage(chanmodes, t_params) + >>> mm.get_mode_info('e', False) + ('A', True, False) + >>> mm.get_mode_info('k', False) + ('B', False, False) + >>> mm.get_mode_info('v', True) + (None, True, True) + + .. note:: + + A user privilege ``mode`` doesn't have a type so the first value + returned will be ``None`` in that case. + """ + try: + letter = self.get_mode_type(mode) + except ModeTypeUnknown: + if mode in self.privileges: + # a user privilege doesn't have a type + return None, True, True + # not a user privilege: re-raise error + raise + + if letter not in self.type_params: + # we don't know how to handle this type of mode + raise ModeTypeImproperlyConfigured(mode, letter) + + type_param = self.type_params[letter] + return letter, not type_param == PARAM_NEVER and ( + type_param == PARAM_ALWAYS + or type_param == PARAM_ADDED and is_added + or type_param == PARAM_REMOVED and not is_added + ), False + + def parse_modestring( + self, + modestring: str, + params: Tuple[str, ...] + ) -> ModeString: + """Parse a ``modestring`` for a channel with its ``params``. + + :return: the parsed and validated information for that ``modestring`` + """ + imodes = iter(_modes_is_added(modestring)) + iparams = iter(params) + modes: List = [] + privileges: List = [] + for mode, is_added in imodes: + param = None + try: + letter, required, is_priv = self.get_mode_info(mode, is_added) + if required: + try: + param = next(iparams) + except StopIteration: + # Not enough parameters: we have to stop here + return ModeString( + tuple(modes), + tuple(privileges), + ((mode, is_added),) + tuple(imodes), + tuple(), + ) + except ModeException as modeerror: + LOGGER.debug( + 'Invalid modestring: %r; error: %s', + modestring, + modeerror, + ) + return ModeString( + tuple(modes), + tuple(privileges), + ((mode, is_added),) + tuple(imodes), + tuple(iparams), + ) + + if is_priv: + privileges.append((mode, is_added, param)) + else: + modes.append((letter, mode, is_added, param)) + + return ModeString( + tuple(modes), + tuple(privileges), + tuple(imodes), + tuple(iparams), + ) diff --git a/test/irc/test_irc_modes.py b/test/irc/test_irc_modes.py new file mode 100644 index 0000000000..6a30cc90d0 --- /dev/null +++ b/test/irc/test_irc_modes.py @@ -0,0 +1,627 @@ +from __future__ import generator_stop + +import pytest + +from sopel.irc.modes import ( + ModeMessage, + ModeTypeImproperlyConfigured, + ModeTypeUnknown, + PARAM_ADDED, + PARAM_ALWAYS, + PARAM_NEVER, + PARAM_REMOVED, +) + +ADDED = True +REMOVED = False +REQUIRED = True +NOT_REQUIRED = False +PREFIX = True +NOT_PREFIX = False + + +@pytest.mark.parametrize('mode, letter', ( + ('b', 'A'), + ('c', 'A'), + ('e', 'B'), + ('f', 'B'), + ('g', 'B'), +)) +def test_modemessage_get_mode_type(mode, letter): + modemessage = ModeMessage({ + 'A': tuple('bc'), + 'B': tuple('efg'), + }, {}) + + assert modemessage.get_mode_type(mode) == letter + + +def test_modemessage_get_mode_type_empty(): + modemessage = ModeMessage({}, {}) + + # common mode + with pytest.raises(ModeTypeUnknown): + modemessage.get_mode_type('b') + + # common privilege + with pytest.raises(ModeTypeUnknown): + modemessage.get_mode_type('v') + + +def test_modemessage_get_mode_type_unknown(): + modemessage = ModeMessage({ + 'A': tuple('bc'), + 'B': tuple('efg'), + }, {}) + + # unknown mode + with pytest.raises(ModeTypeUnknown): + modemessage.get_mode_type('z') + + # common privilege + with pytest.raises(ModeTypeUnknown): + modemessage.get_mode_type('v') + + +@pytest.mark.parametrize('mode, is_added, result', ( + # X: always + ('b', ADDED, ('X', REQUIRED, NOT_PREFIX)), + ('b', REMOVED, ('X', REQUIRED, NOT_PREFIX)), + ('c', ADDED, ('X', REQUIRED, NOT_PREFIX)), + ('c', REMOVED, ('X', REQUIRED, NOT_PREFIX)), + # Y: added only + ('e', ADDED, ('Y', REQUIRED, NOT_PREFIX)), + ('e', REMOVED, ('Y', NOT_REQUIRED, NOT_PREFIX)), + ('f', ADDED, ('Y', REQUIRED, NOT_PREFIX)), + ('f', REMOVED, ('Y', NOT_REQUIRED, NOT_PREFIX)), + ('g', ADDED, ('Y', REQUIRED, NOT_PREFIX)), + ('g', REMOVED, ('Y', NOT_REQUIRED, NOT_PREFIX)), + # Z: removed only + ('i', ADDED, ('Z', NOT_REQUIRED, NOT_PREFIX)), + ('i', REMOVED, ('Z', REQUIRED, NOT_PREFIX)), + ('j', ADDED, ('Z', NOT_REQUIRED, NOT_PREFIX)), + ('j', REMOVED, ('Z', REQUIRED, NOT_PREFIX)), + # T: never + ('k', ADDED, ('T', NOT_REQUIRED, NOT_PREFIX)), + ('k', REMOVED, ('T', NOT_REQUIRED, NOT_PREFIX)), + ('l', ADDED, ('T', NOT_REQUIRED, NOT_PREFIX)), + ('l', REMOVED, ('T', NOT_REQUIRED, NOT_PREFIX)), + ('m', ADDED, ('T', NOT_REQUIRED, NOT_PREFIX)), + ('m', REMOVED, ('T', NOT_REQUIRED, NOT_PREFIX)), + # PREFIX: always + ('v', ADDED, (None, REQUIRED, PREFIX)), + ('v', REMOVED, (None, REQUIRED, PREFIX)), + ('h', ADDED, (None, REQUIRED, PREFIX)), + ('h', REMOVED, (None, REQUIRED, PREFIX)), + ('o', ADDED, (None, REQUIRED, PREFIX)), + ('o', REMOVED, (None, REQUIRED, PREFIX)), + ('a', ADDED, (None, REQUIRED, PREFIX)), + ('a', REMOVED, (None, REQUIRED, PREFIX)), + ('q', ADDED, (None, REQUIRED, PREFIX)), + ('q', REMOVED, (None, REQUIRED, PREFIX)), + ('y', ADDED, (None, REQUIRED, PREFIX)), + ('y', REMOVED, (None, REQUIRED, PREFIX)), + ('Y', ADDED, (None, REQUIRED, PREFIX)), + ('Y', REMOVED, (None, REQUIRED, PREFIX)), +)) +def test_modemessage_get_mode_info(mode, is_added, result): + modemessage = ModeMessage({ + 'X': tuple('bc'), + 'Y': tuple('efg'), + 'Z': tuple('ij'), + 'T': tuple('klm'), + }, { + 'X': PARAM_ALWAYS, + 'Y': PARAM_ADDED, + 'Z': PARAM_REMOVED, + 'T': PARAM_NEVER, + }) + + assert modemessage.get_mode_info(mode, is_added) == result + + +def test_modemessage_get_mode_info_empty(): + modemessage = ModeMessage(chanmodes={}, type_params={}, privileges=set()) + + with pytest.raises(ModeTypeUnknown): + modemessage.get_mode_info('b', ADDED) + + with pytest.raises(ModeTypeUnknown): + modemessage.get_mode_info('b', REMOVED) + + +@pytest.mark.parametrize('privilege', ('v', 'h', 'a', 'q', 'o', 'y', 'Y')) +def test_modemessage_get_mode_info_empty_default_privileges(privilege): + modemessage = ModeMessage(chanmodes={}, type_params={}) + + assert modemessage.get_mode_info(privilege, ADDED), (REQUIRED, PREFIX) + assert modemessage.get_mode_info(privilege, REMOVED), (REQUIRED, PREFIX) + + +@pytest.mark.parametrize('privilege', ('v', 'h', 'a', 'q', 'o', 'y', 'Y')) +def test_modemessage_get_mode_info_empty_privileges_config(privilege): + modemessage = ModeMessage(chanmodes={}, type_params={}, privileges=set()) + + with pytest.raises(ModeTypeUnknown): + modemessage.get_mode_info(privilege, ADDED) + + with pytest.raises(ModeTypeUnknown): + modemessage.get_mode_info(privilege, REMOVED) + + +def test_modemessage_get_mode_info_no_param_config(): + modemessage = ModeMessage({ + 'X': tuple('bc'), + 'Y': tuple('efg'), + 'Z': tuple('ij'), + 'T': tuple('klm'), + }, {}) + + with pytest.raises(ModeTypeImproperlyConfigured): + modemessage.get_mode_info('b', ADDED) + + with pytest.raises(ModeTypeImproperlyConfigured): + modemessage.get_mode_info('b', REMOVED) + + +def test_modemessage_get_mode_info_custom_privileges(): + modemessage = ModeMessage(chanmodes={}, type_params={}, privileges=set('b')) + + assert modemessage.get_mode_info('b', ADDED), (REQUIRED, PREFIX) + assert modemessage.get_mode_info('b', REMOVED), (REQUIRED, PREFIX) + + with pytest.raises(ModeTypeUnknown): + modemessage.get_mode_info('v', ADDED) + + with pytest.raises(ModeTypeUnknown): + modemessage.get_mode_info('v', REMOVED) + + +def test_modemessage_parse_modestring_single_mode(): + modemessage = ModeMessage({ + 'X': tuple('bc'), + 'Y': tuple('efg'), + 'Z': tuple('ij'), + 'T': tuple('klm'), + }, { + 'X': PARAM_ALWAYS, + 'Y': PARAM_ADDED, + 'Z': PARAM_REMOVED, + 'T': PARAM_NEVER, + }) + + # X: always a parameter + result = modemessage.parse_modestring('+b', ('Arg1',)) + assert result.modes == (('X', 'b', ADDED, 'Arg1'),) + assert not result.ignored_modes + assert not result.privileges + assert not result.leftover_params + + result = modemessage.parse_modestring('-b', ('Arg1',)) + assert result.modes == (('X', 'b', REMOVED, 'Arg1'),) + assert not result.ignored_modes + assert not result.privileges + assert not result.leftover_params + + # Y: parameter when added only + result = modemessage.parse_modestring('+e', ('Arg1',)) + assert result.modes == (('Y', 'e', ADDED, 'Arg1'),) + assert not result.ignored_modes + assert not result.privileges + assert not result.leftover_params + + result = modemessage.parse_modestring('-e', ('Arg1',)) + assert result.modes == (('Y', 'e', REMOVED, None),) + assert not result.ignored_modes + assert not result.privileges + assert result.leftover_params == ('Arg1',) + + # Z: parameter when removed only + result = modemessage.parse_modestring('+i', ('Arg1',)) + assert result.modes == (('Z', 'i', ADDED, None),) + assert not result.ignored_modes + assert not result.privileges + assert result.leftover_params == ('Arg1',) + + result = modemessage.parse_modestring('-i', ('Arg1',)) + assert result.modes == (('Z', 'i', REMOVED, 'Arg1'),) + assert not result.ignored_modes + assert not result.privileges + assert not result.leftover_params + + # T: no parameter + result = modemessage.parse_modestring('+k', ('Arg1',)) + assert result.modes == (('T', 'k', ADDED, None),) + assert not result.ignored_modes + assert not result.privileges + assert result.leftover_params == ('Arg1',) + + result = modemessage.parse_modestring('-k', ('Arg1',)) + assert result.modes == (('T', 'k', REMOVED, None),) + assert not result.ignored_modes + assert not result.privileges + assert result.leftover_params == ('Arg1',) + + # Common privilege + result = modemessage.parse_modestring('+v', ('Arg1',)) + assert not result.modes + assert not result.ignored_modes + assert result.privileges == (('v', ADDED, 'Arg1'),) + assert not result.leftover_params + + result = modemessage.parse_modestring('-v', ('Arg1',)) + assert not result.modes + assert not result.ignored_modes + assert result.privileges == (('v', REMOVED, 'Arg1'),) + assert not result.leftover_params + + +def test_modemessage_parse_modestring_multi_mode_add_only(): + modemessage = ModeMessage({ + 'X': tuple('bc'), + 'Y': tuple('efg'), + 'Z': tuple('ij'), + 'T': tuple('klm'), + }, { + 'X': PARAM_ALWAYS, + 'Y': PARAM_ADDED, + 'Z': PARAM_REMOVED, + 'T': PARAM_NEVER, + }) + + # modes only + result = modemessage.parse_modestring('+beik', ('Arg1', 'Arg2')) + assert result.modes == ( + ('X', 'b', ADDED, 'Arg1'), + ('Y', 'e', ADDED, 'Arg2'), + ('Z', 'i', ADDED, None), + ('T', 'k', ADDED, None), + ) + assert not result.ignored_modes + assert not result.privileges + assert not result.leftover_params + + # privileges only + result = modemessage.parse_modestring('+vo', ('Arg1', 'Arg2')) + assert not result.modes + assert not result.ignored_modes + assert result.privileges == ( + ('v', ADDED, 'Arg1'), + ('o', ADDED, 'Arg2'), + ) + assert not result.leftover_params + + # modes & privileges + result = modemessage.parse_modestring( + '+bveoik', ('Arg1', 'Arg2', 'Arg3', 'Arg4')) + assert result.modes == ( + ('X', 'b', ADDED, 'Arg1'), + ('Y', 'e', ADDED, 'Arg3'), + ('Z', 'i', ADDED, None), + ('T', 'k', ADDED, None), + ) + assert not result.ignored_modes + assert result.privileges == ( + ('v', ADDED, 'Arg2'), + ('o', ADDED, 'Arg4'), + ) + assert not result.leftover_params + + +def test_modemessage_parse_modestring_multi_mode_remove_only(): + modemessage = ModeMessage({ + 'X': tuple('bc'), + 'Y': tuple('efg'), + 'Z': tuple('ij'), + 'T': tuple('klm'), + }, { + 'X': PARAM_ALWAYS, + 'Y': PARAM_ADDED, + 'Z': PARAM_REMOVED, + 'T': PARAM_NEVER, + }) + + # modes only + result = modemessage.parse_modestring('-beik', ('Arg1', 'Arg2')) + assert result.modes == ( + ('X', 'b', REMOVED, 'Arg1'), + ('Y', 'e', REMOVED, None), + ('Z', 'i', REMOVED, 'Arg2'), + ('T', 'k', REMOVED, None), + ) + assert not result.ignored_modes + assert not result.privileges + assert not result.leftover_params + + # privileges only + result = modemessage.parse_modestring('-vo', ('Arg1', 'Arg2')) + assert not result.modes + assert not result.ignored_modes + assert result.privileges == ( + ('v', REMOVED, 'Arg1'), + ('o', REMOVED, 'Arg2'), + ) + assert not result.leftover_params + + # modes & privileges + result = modemessage.parse_modestring( + '-bveoik', ('Arg1', 'Arg2', 'Arg3', 'Arg4')) + assert result.modes == ( + ('X', 'b', REMOVED, 'Arg1'), + ('Y', 'e', REMOVED, None), + ('Z', 'i', REMOVED, 'Arg4'), + ('T', 'k', REMOVED, None), + ) + assert not result.ignored_modes + assert result.privileges == ( + ('v', REMOVED, 'Arg2'), + ('o', REMOVED, 'Arg3'), + ) + assert not result.leftover_params + + +def test_modemessage_parse_modestring_multi_mode_mixed_add_remove(): + modemessage = ModeMessage({ + 'X': tuple('bc'), + 'Y': tuple('efg'), + 'Z': tuple('ij'), + 'T': tuple('klm'), + }, { + 'X': PARAM_ALWAYS, + 'Y': PARAM_ADDED, + 'Z': PARAM_REMOVED, + 'T': PARAM_NEVER, + }) + + # added first + result = modemessage.parse_modestring( + '+bveik-cofjl', ('Arg1', 'Arg2', 'Arg3', 'Arg4', 'Arg5', 'Arg6')) + assert result.modes == ( + ('X', 'b', ADDED, 'Arg1'), + ('Y', 'e', ADDED, 'Arg3'), + ('Z', 'i', ADDED, None), + ('T', 'k', ADDED, None), + ('X', 'c', REMOVED, 'Arg4'), + ('Y', 'f', REMOVED, None), + ('Z', 'j', REMOVED, 'Arg6'), + ('T', 'l', REMOVED, None), + ) + assert not result.ignored_modes + assert result.privileges == ( + ('v', ADDED, 'Arg2'), + ('o', REMOVED, 'Arg5'), + ) + assert not result.leftover_params + + # removed first + result = modemessage.parse_modestring( + '-cofjl+bveik', ('Arg1', 'Arg2', 'Arg3', 'Arg4', 'Arg5', 'Arg6')) + assert result.modes == ( + ('X', 'c', REMOVED, 'Arg1'), + ('Y', 'f', REMOVED, None), + ('Z', 'j', REMOVED, 'Arg3'), + ('T', 'l', REMOVED, None), + ('X', 'b', ADDED, 'Arg4'), + ('Y', 'e', ADDED, 'Arg6'), + ('Z', 'i', ADDED, None), + ('T', 'k', ADDED, None), + ) + assert not result.ignored_modes + assert result.privileges == ( + ('o', REMOVED, 'Arg2'), + ('v', ADDED, 'Arg5'), + ) + assert not result.leftover_params + + # mixed add/remove + result = modemessage.parse_modestring( + '+bve-cof+ik-jl', ('Arg1', 'Arg2', 'Arg3', 'Arg4', 'Arg5', 'Arg6')) + assert result.modes == ( + ('X', 'b', ADDED, 'Arg1'), + ('Y', 'e', ADDED, 'Arg3'), + ('X', 'c', REMOVED, 'Arg4'), + ('Y', 'f', REMOVED, None), + ('Z', 'i', ADDED, None), + ('T', 'k', ADDED, None), + ('Z', 'j', REMOVED, 'Arg6'), + ('T', 'l', REMOVED, None), + ) + assert not result.ignored_modes + assert result.privileges == ( + ('v', ADDED, 'Arg2'), + ('o', REMOVED, 'Arg5'), + ) + assert not result.leftover_params + + +def test_modemessage_parse_modestring_leftover_params(): + modemessage = ModeMessage({ + 'X': tuple('bc'), + 'Y': tuple('efg'), + 'Z': tuple('ij'), + 'T': tuple('klm'), + }, { + 'X': PARAM_ALWAYS, + 'Y': PARAM_ADDED, + 'Z': PARAM_REMOVED, + 'T': PARAM_NEVER, + }) + + # Single mode + result = modemessage.parse_modestring( + '+b', ('Arg1', 'Arg2')) + assert result.modes == ( + ('X', 'b', ADDED, 'Arg1'), + ) + assert not result.ignored_modes + assert not result.privileges + assert result.leftover_params == ('Arg2',) + + # Single privilege + result = modemessage.parse_modestring( + '+v', ('Arg1', 'Arg2')) + assert not result.modes + assert not result.ignored_modes + assert result.privileges == ( + ('v', ADDED, 'Arg1'), + ) + assert result.leftover_params == ('Arg2',) + + # Multi modes + result = modemessage.parse_modestring( + '+be-fi+jk-l', ('Arg1', 'Arg2', 'Arg3', 'Arg4')) + assert result.modes == ( + ('X', 'b', ADDED, 'Arg1'), + ('Y', 'e', ADDED, 'Arg2'), + ('Y', 'f', REMOVED, None), + ('Z', 'i', REMOVED, 'Arg3'), + ('Z', 'j', ADDED, None), + ('T', 'k', ADDED, None), + ('T', 'l', REMOVED, None), + ) + assert not result.ignored_modes + assert not result.privileges + assert result.leftover_params == ('Arg4',) + + +def test_modemessage_parse_modestring_ignored_modes(): + modemessage = ModeMessage({ + 'X': tuple('bc'), + 'Y': tuple('efg'), + 'Z': tuple('ij'), + 'T': tuple('klm'), + }, { + 'X': PARAM_ALWAYS, + 'Y': PARAM_ADDED, + 'Z': PARAM_REMOVED, + 'T': PARAM_NEVER, + }) + + # Single mode + result = modemessage.parse_modestring('+B', ('Arg1',)) + assert not result.modes + assert result.ignored_modes == (('B', ADDED),) + assert not result.privileges + assert result.leftover_params == ('Arg1',) + + # Multi modes/privileges + result = modemessage.parse_modestring( + '+bv+B-o', ('Arg1', 'Arg2', 'Arg3')) + assert result.modes == ( + ('X', 'b', ADDED, 'Arg1'), + ) + assert result.ignored_modes == ( + ('B', ADDED), + ('o', REMOVED), + ) + assert result.privileges == ( + ('v', ADDED, 'Arg2'), + ) + assert result.leftover_params == ('Arg3',) + + +def test_modemessage_parse_modestring_no_params(): + modemessage = ModeMessage({ + 'X': tuple('bc'), + 'Y': tuple('efg'), + 'Z': tuple('ij'), + 'T': tuple('klm'), + }, { + 'X': PARAM_ALWAYS, + 'Y': PARAM_ADDED, + 'Z': PARAM_REMOVED, + 'T': PARAM_NEVER, + }) + + # Single mode + result = modemessage.parse_modestring('+b', tuple()) + assert not result.modes + assert result.ignored_modes == (('b', ADDED),) + assert not result.privileges + assert not result.leftover_params + + result = modemessage.parse_modestring('-b', tuple()) + assert not result.modes + assert result.ignored_modes == (('b', REMOVED),) + assert not result.privileges + assert not result.leftover_params + + # Single privilege + result = modemessage.parse_modestring('+v', tuple()) + assert not result.modes + assert result.ignored_modes == (('v', ADDED),) + assert not result.privileges + assert not result.leftover_params + + result = modemessage.parse_modestring('-v', tuple()) + assert not result.modes + assert result.ignored_modes == (('v', REMOVED),) + assert not result.privileges + assert not result.leftover_params + + # Mixed multi modes/privileges + result = modemessage.parse_modestring('+b-v', tuple()) + assert not result.modes + assert result.ignored_modes == (('b', ADDED), ('v', REMOVED),) + assert not result.privileges + assert not result.leftover_params + + +def test_modemessage_parse_modestring_missing_params(): + modemessage = ModeMessage({ + 'X': tuple('bc'), + 'Y': tuple('efg'), + 'Z': tuple('ij'), + 'T': tuple('klm'), + }, { + 'X': PARAM_ALWAYS, + 'Y': PARAM_ADDED, + 'Z': PARAM_REMOVED, + 'T': PARAM_NEVER, + }) + + # Modes only + result = modemessage.parse_modestring('+bc', ('Arg1',)) + assert result.modes == ( + ('X', 'b', ADDED, 'Arg1'), + ) + assert result.ignored_modes == (('c', ADDED),) + assert not result.privileges + assert not result.leftover_params + + result = modemessage.parse_modestring('-bc', ('Arg1',)) + assert result.modes == ( + ('X', 'b', REMOVED, 'Arg1'), + ) + assert result.ignored_modes == (('c', REMOVED),) + assert not result.privileges + assert not result.leftover_params + + # Prefixes only + result = modemessage.parse_modestring('+vo', ('Arg1',)) + assert not result.modes + assert result.ignored_modes == (('o', ADDED),) + assert result.privileges == ( + ('v', ADDED, 'Arg1'), + ) + assert not result.leftover_params + + result = modemessage.parse_modestring('-vo', ('Arg1',)) + assert not result.modes + assert result.ignored_modes == (('o', REMOVED),) + assert result.privileges == ( + ('v', REMOVED, 'Arg1'), + ) + assert not result.leftover_params + + # Mixed modes/privileges + result = modemessage.parse_modestring('+bv-co', ('Arg1', 'Arg2', 'Arg3')) + assert result.modes == ( + ('X', 'b', ADDED, 'Arg1'), + ('X', 'c', REMOVED, 'Arg3'), + ) + assert result.ignored_modes == (('o', REMOVED),) + assert result.privileges == ( + ('v', ADDED, 'Arg2'), + ) + assert not result.leftover_params