From bc68707fca0abb4a2c905cdd7a249e97ba87cdcc Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Wed, 29 Dec 2021 18:32:04 +0100 Subject: [PATCH] [TO DROP] Commits from #2231 --- docs/source/api.rst | 9 +- docs/source/plugin/bot.rst | 7 +- sopel/bot.py | 34 ++-- sopel/config/core_section.py | 2 +- sopel/coretasks.py | 56 ++++-- sopel/db.py | 254 +++++++++++++++---------- sopel/irc/__init__.py | 30 ++- sopel/modules/adminchannel.py | 16 +- sopel/modules/clock.py | 4 +- sopel/modules/find.py | 14 +- sopel/modules/invite.py | 8 +- sopel/modules/meetbot.py | 2 +- sopel/modules/seen.py | 4 +- sopel/modules/tell.py | 8 +- sopel/modules/translate.py | 4 +- sopel/modules/url.py | 4 +- sopel/tests/factories.py | 54 ++++-- sopel/tools/__init__.py | 166 ++++------------ sopel/tools/identifiers.py | 270 +++++++++++++++++++++++++++ sopel/tools/target.py | 160 +++++++++------- sopel/tools/time.py | 7 +- sopel/trigger.py | 91 ++++++--- test/test_coretasks.py | 20 ++ test/test_db.py | 95 +++++++--- test/tools/test_tools_identifiers.py | 178 ++++++++++++++++++ 25 files changed, 1064 insertions(+), 433 deletions(-) create mode 100644 sopel/tools/identifiers.py create mode 100644 test/tools/test_tools_identifiers.py diff --git a/docs/source/api.rst b/docs/source/api.rst index d95d7f8c1b..737085c21a 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -10,7 +10,14 @@ sopel.tools .. automodule:: sopel.tools :members: :exclude-members: iteritems, iterkeys, itervalues, raw_input - :private-members: Identifier._lower, Identifier._lower_swapped + + +sopel.tools.identifiers +----------------------- + +.. automodule:: sopel.tools.identifiers + :members: + sopel.tools.web --------------- diff --git a/docs/source/plugin/bot.rst b/docs/source/plugin/bot.rst index 15c368f869..098097ab9d 100644 --- a/docs/source/plugin/bot.rst +++ b/docs/source/plugin/bot.rst @@ -23,7 +23,8 @@ second argument:: bot.say('The bot is now talking!', '#private-channel') -Instead of a string, you can use an instance of :class:`sopel.tools.Identifier`. +Instead of a string, you can use an instance of +:class:`~sopel.tools.identifiers.Identifier`. If you want to reply to a user in a private message, you can use the trigger's :attr:`~sopel.trigger.Trigger.nick` attribute as destination:: @@ -261,8 +262,8 @@ which provides the following information: if not trigger.sender.is_nick(): # this trigger is from a channel - See :meth:`Identifier.is_nick() ` for - more information. + See :meth:`Identifier.is_nick() ` + for more information. Getting users in a channel -------------------------- diff --git a/sopel/bot.py b/sopel/bot.py index 78f497888a..d383d5e383 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -22,7 +22,7 @@ from sopel import db, irc, logger, plugin, plugins, tools from sopel.irc import modes from sopel.plugins import jobs as plugin_jobs, rules as plugin_rules -from sopel.tools import deprecated, Identifier, jobs as tools_jobs +from sopel.tools import deprecated, jobs as tools_jobs from sopel.trigger import PreTrigger, Trigger @@ -81,23 +81,28 @@ def __init__(self, config, daemon=False): self.modeparser = modes.ModeParser() """A mode parser used to parse ``MODE`` messages and modestrings.""" - self.channels = tools.SopelIdentifierMemory() + self.channels = tools.SopelIdentifierMemory( + identifier_factory=self.make_identifier, + ) """A map of the channels that Sopel is in. - The keys are :class:`sopel.tools.Identifier`\\s of the channel names, - and map to :class:`sopel.tools.target.Channel` objects which contain - the users in the channel and their permissions. + The keys are :class:`~sopel.tools.identifiers.Identifier`\\s of the + channel names, and map to :class:`~sopel.tools.target.Channel` objects + which contain the users in the channel and their permissions. """ - self.users = tools.SopelIdentifierMemory() + self.users = tools.SopelIdentifierMemory( + identifier_factory=self.make_identifier, + ) """A map of the users that Sopel is aware of. - The keys are :class:`sopel.tools.Identifier`\\s of the nicknames, and - map to :class:`sopel.tools.target.User` instances. In order for Sopel - to be aware of a user, it must share at least one mutual channel. + The keys are :class:`~sopel.tools.identifiers.Identifier`\\s of the + nicknames, and map to :class:`~sopel.tools.target.User` instances. In + order for Sopel to be aware of a user, it must share at least one + mutual channel. """ - self.db = db.SopelDB(config) + self.db = db.SopelDB(config, identifier_factory=self.make_identifier) """The bot's database, as a :class:`sopel.db.SopelDB` instance.""" self.memory = tools.SopelMemory() @@ -212,9 +217,10 @@ def has_channel_privilege(self, channel, privilege) -> bool: >>> bot.has_channel_privilege('#chan', plugin.VOICE) True - The ``channel`` argument can be either a :class:`str` or a - :class:`sopel.tools.Identifier`, as long as Sopel joined said channel. - If the channel is unknown, a :exc:`ValueError` will be raised. + The ``channel`` argument can be either a :class:`str` or an + :class:`~sopel.tools.identifiers.Identifier`, as long as Sopel joined + said channel. If the channel is unknown, a :exc:`ValueError` will be + raised. """ if channel not in self.channels: raise ValueError('Unknown channel %s' % channel) @@ -987,7 +993,7 @@ def _nick_blocked(self, nick: str) -> bool: if not bad_nick: continue if (re.match(bad_nick + '$', nick, re.IGNORECASE) or - Identifier(bad_nick) == nick): + self.make_identifier(bad_nick) == nick): return True return False diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py index 495cf3afce..5fec2a7ce5 100644 --- a/sopel/config/core_section.py +++ b/sopel/config/core_section.py @@ -880,7 +880,7 @@ def homedir(self): :default: ``Sopel: https://sopel.chat/`` """ - nick = ValidatedAttribute('nick', Identifier, default=Identifier('Sopel')) + nick = ValidatedAttribute('nick', default='Sopel') """The nickname for the bot. :default: ``Sopel`` diff --git a/sopel/coretasks.py b/sopel/coretasks.py index 9572979623..3580c6c5b2 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -33,7 +33,7 @@ from sopel import config, plugin from sopel.irc import isupport, utils -from sopel.tools import events, Identifier, jobs, SopelMemory, target +from sopel.tools import events, jobs, SopelMemory, target LOGGER = logging.getLogger(__name__) @@ -143,7 +143,7 @@ def auth_after_register(bot): # nickserv-based auth method needs to check for current nick if auth_method == 'nickserv': - if bot.nick != bot.settings.core.nick: + if bot.nick != bot.make_identifier(bot.settings.core.nick): LOGGER.warning("Sending nickserv GHOST command.") bot.say( 'GHOST %s %s' % (bot.settings.core.nick, auth_password), @@ -347,6 +347,7 @@ def handle_isupport(bot, trigger): botmode_support = 'BOT' in bot.isupport namesx_support = 'NAMESX' in bot.isupport uhnames_support = 'UHNAMES' in bot.isupport + casemapping_support = 'CASEMAPPING' in bot.isupport # parse ISUPPORT message from server parameters = {} @@ -367,6 +368,11 @@ def handle_isupport(bot, trigger): if 'PREFIX' in bot.isupport: bot.modeparser.privileges = set(bot.isupport.PREFIX.keys()) + # was CASEMAPPING support status updated? + if not casemapping_support and 'CASEMAPPING' in bot.isupport: + # Re-create the bot's nick with the proper identifier+casemapping + bot.rebuild_nick() + # was BOT mode support status updated? if not botmode_support and 'BOT' in bot.isupport: # yes it was! set our mode unless the config overrides it @@ -482,9 +488,12 @@ def handle_names(bot, trigger): channels = re.search(r'(#\S*)', trigger.raw) if not channels: return - channel = Identifier(channels.group(1)) + channel = bot.make_identifier(channels.group(1)) if channel not in bot.channels: - bot.channels[channel] = target.Channel(channel) + bot.channels[channel] = target.Channel( + channel, + identifier_factory=bot.make_identifier, + ) # This could probably be made flexible in the future, but I don't think # it'd be worth it. @@ -515,7 +524,7 @@ def handle_names(bot, trigger): if prefix in name: priv = priv | value - nick = Identifier(name.lstrip(''.join(mapping.keys()))) + nick = bot.make_identifier(name.lstrip(''.join(mapping.keys()))) user = bot.users.get(nick) if user is None: # The username/hostname will be included in a NAMES reply only if @@ -559,7 +568,7 @@ def _parse_modes(bot, args, clear=False): modes at https://modern.ircdocs.horse/#channel-mode """ - channel_name = Identifier(args[0]) + channel_name = bot.make_identifier(args[0]) if channel_name.is_nick(): # We don't do anything with user modes LOGGER.debug("Ignoring user modes: %r", args) @@ -622,7 +631,7 @@ def _parse_modes(bot, args, clear=False): # modeinfo.privileges contains only the valid parsed privileges for privilege, is_added, param in modeinfo.privileges: # User privs modes, always have a param - nick = Identifier(param) + nick = bot.make_identifier(param) priv = channel.privileges.get(nick, 0) value = MODE_PREFIX_PRIVILEGES[privilege] if is_added: @@ -659,7 +668,7 @@ def _parse_modes(bot, args, clear=False): def track_nicks(bot, trigger): """Track nickname changes and maintain our chanops list accordingly.""" old = trigger.nick - new = Identifier(trigger) + new = bot.make_identifier(trigger) # Give debug message, and PM the owner, if the bot's own nick changes. if old == bot.nick and new != bot.nick: @@ -705,7 +714,7 @@ def track_part(bot, trigger): @plugin.priority('medium') def track_kick(bot, trigger): """Track users kicked from channels.""" - nick = Identifier(trigger.args[1]) + nick = bot.make_identifier(trigger.args[1]) channel = trigger.sender _remove_from_channel(bot, nick, channel) LOGGER.info( @@ -748,7 +757,9 @@ def _send_who(bot, channel): # We might be on an old network, but we still care about keeping our # user list updated bot.write(['WHO', channel]) - bot.channels[Identifier(channel)].last_who = datetime.datetime.utcnow() + + channel_id = bot.make_identifier(channel) + bot.channels[channel_id].last_who = datetime.datetime.utcnow() @plugin.interval(30) @@ -793,7 +804,10 @@ def track_join(bot, trigger): # is it a new channel? if channel not in bot.channels: - bot.channels[channel] = target.Channel(channel) + bot.channels[channel] = target.Channel( + channel, + identifier_factory=bot.make_identifier, + ) # did *we* just join? if trigger.nick == bot.nick: @@ -836,7 +850,8 @@ def track_quit(bot, trigger): LOGGER.info("User quit: %s", trigger.nick) - if trigger.nick == bot.settings.core.nick and trigger.nick != bot.nick: + configured_nick = bot.make_identifier(bot.settings.core.nick) + if trigger.nick == configured_nick and trigger.nick != bot.nick: # old nick is now available, let's change nick again bot.change_current_nick(bot.settings.core.nick) auth_after_register(bot) @@ -1229,7 +1244,7 @@ def blocks(bot, trigger): } masks = set(s for s in bot.config.core.host_blocks if s != '') - nicks = set(Identifier(nick) + nicks = set(bot.make_identifier(nick) for nick in bot.config.core.nick_blocks if nick != '') text = trigger.group().split() @@ -1266,10 +1281,11 @@ def blocks(bot, trigger): elif len(text) == 4 and text[1] == "del": if text[2] == "nick": - if Identifier(text[3]) not in nicks: + nick = bot.make_identifier(text[3]) + if nick not in nicks: bot.reply(STRINGS['no_nick'] % (text[3])) return - nicks.remove(Identifier(text[3])) + nicks.remove(nick) bot.config.core.nick_blocks = [str(n) for n in nicks] bot.config.save() bot.reply(STRINGS['success_del'] % (text[3])) @@ -1352,8 +1368,8 @@ def recv_whox(bot, trigger): def _record_who(bot, channel, user, host, nick, account=None, away=None, modes=None): - nick = Identifier(nick) - channel = Identifier(channel) + nick = bot.make_identifier(nick) + channel = bot.make_identifier(channel) if nick not in bot.users: usr = target.User(nick, user, host) bot.users[nick] = usr @@ -1383,7 +1399,11 @@ def _record_who(bot, channel, user, host, nick, account=None, away=None, modes=N for c in modes: priv = priv | mapping[c] if channel not in bot.channels: - bot.channels[channel] = target.Channel(channel) + bot.channels[channel] = target.Channel( + channel, + identifier_factory=bot.make_identifier, + ) + bot.channels[channel].add_user(usr, privs=priv) diff --git a/sopel/db.py b/sopel/db.py index 1d1041576b..84b89ec62c 100644 --- a/sopel/db.py +++ b/sopel/db.py @@ -5,6 +5,7 @@ import logging import os.path import traceback +import typing from sqlalchemy import Column, create_engine, ForeignKey, Integer, String from sqlalchemy.engine.url import make_url, URL @@ -12,10 +13,12 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import scoped_session, sessionmaker -from sopel.tools import deprecated, Identifier +from sopel.tools import deprecated +from sopel.tools.identifiers import Identifier LOGGER = logging.getLogger(__name__) +IdentifierFactory = typing.Callable[[str], Identifier] def _deserialize(value): @@ -86,6 +89,9 @@ class SopelDB: :param config: Sopel's configuration settings :type config: :class:`sopel.config.Config` + :param identifier_factory: factory for + :class:`~sopel.tools.identifiers.Identifier` + :type: Callable[[:class:`str`], :class:`str`] This defines a simplified interface for basic, common operations on the bot's database. Direct access to the database is also available, to serve @@ -103,9 +109,21 @@ class SopelDB: of database they use (especially on high-load Sopel instances, which may run up against SQLite's concurrent-access limitations). + .. versionchanged:: 8.0 + + An Identifier factory can be provided that will be used to instantiate + :class:`~sopel.tools.identifiers.Identifier` when dealing with Nick or + Channel names. + """ - def __init__(self, config): + def __init__( + self, + config, + identifier_factory: IdentifierFactory = Identifier, + ) -> None: + self.make_identifier = identifier_factory + if config.core.db_url is not None: self.url = make_url(config.core.db_url) @@ -256,13 +274,12 @@ def get_uri(self): # NICK FUNCTIONS - def get_nick_id(self, nick, create=False): + def get_nick_id(self, nick: str, create: bool = False) -> int: """Return the internal identifier for a given nick. :param nick: the nickname for which to fetch an ID - :type nick: :class:`~sopel.tools.Identifier` - :param bool create: whether to create an ID if one does not exist - (set to ``False`` by default) + :param create: whether to create an ID if one does not exist + (set to ``False`` by default) :raise ValueError: if no ID exists for the given ``nick`` and ``create`` is set to ``False`` :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error @@ -282,7 +299,7 @@ def get_nick_id(self, nick, create=False): """ session = self.ssession() - slug = nick.lower() + slug = self.make_identifier(nick).lower() try: nickname = session.query(Nicknames) \ .filter(Nicknames.slug == slug) \ @@ -307,7 +324,11 @@ def get_nick_id(self, nick, create=False): session.commit() # Create a new Nickname - nickname = Nicknames(nick_id=nick_id.nick_id, slug=slug, canonical=nick) + nickname = Nicknames( + nick_id=nick_id.nick_id, + slug=slug, + canonical=nick, + ) session.add(nickname) session.commit() return nickname.nick_id @@ -317,11 +338,11 @@ def get_nick_id(self, nick, create=False): finally: self.ssession.remove() - def alias_nick(self, nick, alias): + def alias_nick(self, nick: str, alias: str) -> None: """Create an alias for a nick. - :param str nick: an existing nickname - :param str alias: an alias by which ``nick`` should also be known + :param nick: an existing nickname + :param alias: an alias by which ``nick`` should also be known :raise ValueError: if the ``alias`` already exists :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error @@ -333,18 +354,21 @@ def alias_nick(self, nick, alias): :meth:`unalias_nick`. """ - nick = Identifier(nick) - alias = Identifier(alias) + slug = self.make_identifier(alias).lower() nick_id = self.get_nick_id(nick, create=True) session = self.ssession() try: result = session.query(Nicknames) \ - .filter(Nicknames.slug == alias.lower()) \ + .filter(Nicknames.slug == slug) \ .filter(Nicknames.canonical == alias) \ .one_or_none() if result: raise ValueError('Alias already exists.') - nickname = Nicknames(nick_id=nick_id, slug=alias.lower(), canonical=alias) + nickname = Nicknames( + nick_id=nick_id, + slug=slug, + canonical=alias, + ) session.add(nickname) session.commit() except SQLAlchemyError: @@ -353,12 +377,12 @@ def alias_nick(self, nick, alias): finally: self.ssession.remove() - def set_nick_value(self, nick, key, value): + def set_nick_value(self, nick: str, key: str, value: typing.Any) -> None: """Set or update a value in the key-value store for ``nick``. - :param str nick: the nickname with which to associate the ``value`` - :param str key: the name by which this ``value`` may be accessed later - :param mixed value: the value to set for this ``key`` under ``nick`` + :param nick: the nickname with which to associate the ``value`` + :param key: the name by which this ``value`` may be accessed later + :param value: the value to set for this ``key`` under ``nick`` :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error The ``value`` can be any of a range of types; it need not be a string. @@ -374,7 +398,6 @@ def set_nick_value(self, nick, key, value): :meth:`delete_nick_value`. """ - nick = Identifier(nick) value = json.dumps(value, ensure_ascii=False) nick_id = self.get_nick_id(nick, create=True) session = self.ssession() @@ -389,7 +412,11 @@ def set_nick_value(self, nick, key, value): session.commit() # DNE - Insert else: - new_nickvalue = NickValues(nick_id=nick_id, key=key, value=value) + new_nickvalue = NickValues( + nick_id=nick_id, + key=key, + value=value, + ) session.add(new_nickvalue) session.commit() except SQLAlchemyError: @@ -398,11 +425,11 @@ def set_nick_value(self, nick, key, value): finally: self.ssession.remove() - def delete_nick_value(self, nick, key): + def delete_nick_value(self, nick: str, key: str) -> None: """Delete a value from the key-value store for ``nick``. - :param str nick: the nickname whose values to modify - :param str key: the name of the value to delete + :param nick: the nickname whose values to modify + :param key: the name of the value to delete :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error .. seealso:: @@ -413,8 +440,6 @@ def delete_nick_value(self, nick, key): :meth:`get_nick_value`. """ - nick = Identifier(nick) - try: nick_id = self.get_nick_id(nick) except ValueError: @@ -437,13 +462,18 @@ def delete_nick_value(self, nick, key): finally: self.ssession.remove() - def get_nick_value(self, nick, key, default=None): + def get_nick_value( + self, + nick: str, + key: str, + default: typing.Optional[typing.Any] = None + ) -> typing.Optional[typing.Any]: """Get a value from the key-value store for ``nick``. - :param str nick: the nickname whose values to access - :param str key: the name by which the desired value was saved - :param mixed default: value to return if ``key`` does not have a value - set (optional) + :param nick: the nickname whose values to access + :param key: the name by which the desired value was saved + :param default: value to return if ``key`` does not have a value set + (optional) :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error .. versionadded:: 7.0 @@ -459,12 +489,12 @@ def get_nick_value(self, nick, key, default=None): :meth:`delete_nick_value`. """ - nick = Identifier(nick) + slug = self.make_identifier(nick).lower() session = self.ssession() try: result = session.query(NickValues) \ .filter(Nicknames.nick_id == NickValues.nick_id) \ - .filter(Nicknames.slug == nick.lower()) \ + .filter(Nicknames.slug == slug) \ .filter(NickValues.key == key) \ .one_or_none() if result is not None: @@ -478,10 +508,10 @@ def get_nick_value(self, nick, key, default=None): finally: self.ssession.remove() - def unalias_nick(self, alias): + def unalias_nick(self, alias: str) -> None: """Remove an alias. - :param str alias: an alias with at least one other nick in its group + :param alias: an alias with at least one other nick in its group :raise ValueError: if there is not at least one other nick in the group, or the ``alias`` is not known :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error @@ -493,7 +523,7 @@ def unalias_nick(self, alias): To *add* an alias for a nick, use :meth:`alias_nick`. """ - alias = Identifier(alias) + slug = self.make_identifier(alias).lower() nick_id = self.get_nick_id(alias) session = self.ssession() try: @@ -502,7 +532,7 @@ def unalias_nick(self, alias): .count() if count <= 1: raise ValueError('Given alias is the only entry in its group.') - session.query(Nicknames).filter(Nicknames.slug == alias.lower()).delete() + session.query(Nicknames).filter(Nicknames.slug == slug).delete() session.commit() except SQLAlchemyError: session.rollback() @@ -510,10 +540,10 @@ def unalias_nick(self, alias): finally: self.ssession.remove() - def forget_nick_group(self, nick): + def forget_nick_group(self, nick: str) -> None: """Remove a nickname, all of its aliases, and all of its stored values. - :param str nick: one of the nicknames in the group to be deleted + :param nick: one of the nicknames in the group to be deleted :raise ValueError: if the ``nick`` does not exist in the database :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error @@ -523,7 +553,6 @@ def forget_nick_group(self, nick): you want to do this. """ - nick = Identifier(nick) nick_id = self.get_nick_id(nick) session = self.ssession() try: @@ -541,14 +570,14 @@ def forget_nick_group(self, nick): removed_in='9.0', reason="Renamed to `forget_nick_group`", ) - def delete_nick_group(self, nick): # pragma: nocover + def delete_nick_group(self, nick: str) -> None: # pragma: nocover self.forget_nick_group(nick) - def merge_nick_groups(self, first_nick, second_nick): + def merge_nick_groups(self, first_nick: str, second_nick: str): """Merge two nick groups. - :param str first_nick: one nick in the first group to merge - :param str second_nick: one nick in the second group to merge + :param first_nick: one nick in the first group to merge + :param second_nick: one nick in the second group to merge :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error Takes two nicks, which may or may not be registered. Unregistered nicks @@ -564,8 +593,8 @@ def merge_nick_groups(self, first_nick, second_nick): Plugins which define their own tables relying on the nick table will need to handle their own merging separately. """ - first_id = self.get_nick_id(Identifier(first_nick), create=True) - second_id = self.get_nick_id(Identifier(second_nick), create=True) + first_id = self.get_nick_id(first_nick, create=True) + second_id = self.get_nick_id(second_nick, create=True) session = self.ssession() try: # Get second_id's values @@ -591,21 +620,18 @@ def merge_nick_groups(self, first_nick, second_nick): # CHANNEL FUNCTIONS - def get_channel_slug(self, chan): + def get_channel_slug(self, chan: str) -> str: """Return the case-normalized representation of ``channel``. - :param str channel: the channel name to normalize, with prefix - (required) - :return str: the case-normalized channel name (or "slug" - representation) + :param channel: the channel name to normalize, with prefix (required) + :return: the case-normalized channel name (or "slug" representation) This is useful to make sure that a channel name is stored consistently in both the bot's own database and third-party plugins' databases/files, without regard for variation in case between different clients and/or servers on the network. """ - chan = Identifier(chan) - slug = chan.lower() + slug = self.make_identifier(chan).lower() session = self.ssession() try: count = session.query(ChannelValues) \ @@ -629,12 +655,17 @@ def get_channel_slug(self, chan): finally: self.ssession.remove() - def set_channel_value(self, channel, key, value): + def set_channel_value( + self, + channel: str, + key: str, + value: typing.Any, + ) -> None: """Set or update a value in the key-value store for ``channel``. - :param str channel: the channel with which to associate the ``value`` - :param str key: the name by which this ``value`` may be accessed later - :param mixed value: the value to set for this ``key`` under ``channel`` + :param channel: the channel with which to associate the ``value`` + :param key: the name by which this ``value`` may be accessed later + :param value: the value to set for this ``key`` under ``channel`` :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error The ``value`` can be any of a range of types; it need not be a string. @@ -664,7 +695,11 @@ def set_channel_value(self, channel, key, value): session.commit() # DNE - Insert else: - new_channelvalue = ChannelValues(channel=channel, key=key, value=value) + new_channelvalue = ChannelValues( + channel=channel, + key=key, + value=value, + ) session.add(new_channelvalue) session.commit() except SQLAlchemyError: @@ -673,11 +708,11 @@ def set_channel_value(self, channel, key, value): finally: self.ssession.remove() - def delete_channel_value(self, channel, key): + def delete_channel_value(self, channel: str, key: str) -> None: """Delete a value from the key-value store for ``channel``. - :param str channel: the channel whose values to modify - :param str key: the name of the value to delete + :param channel: the channel whose values to modify + :param key: the name of the value to delete :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error .. seealso:: @@ -705,13 +740,18 @@ def delete_channel_value(self, channel, key): finally: self.ssession.remove() - def get_channel_value(self, channel, key, default=None): + def get_channel_value( + self, + channel: str, + key: str, + default: typing.Optional[typing.Any] = None, + ): """Get a value from the key-value store for ``channel``. - :param str channel: the channel whose values to access - :param str key: the name by which the desired value was saved - :param mixed default: value to return if ``key`` does not have a value - set (optional) + :param channel: the channel whose values to access + :param key: the name by which the desired value was saved + :param default: value to return if ``key`` does not have a value set + (optional) :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error .. versionadded:: 7.0 @@ -745,10 +785,10 @@ def get_channel_value(self, channel, key, default=None): finally: self.ssession.remove() - def forget_channel(self, channel): + def forget_channel(self, channel: str) -> None: """Remove all of a channel's stored values. - :param str channel: the name of the channel for which to delete values + :param channel: the name of the channel for which to delete values :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error .. important:: @@ -769,12 +809,17 @@ def forget_channel(self, channel): # PLUGIN FUNCTIONS - def set_plugin_value(self, plugin, key, value): + def set_plugin_value( + self, + plugin: str, + key: str, + value: typing.Any, + ) -> None: """Set or update a value in the key-value store for ``plugin``. - :param str plugin: the plugin name with which to associate the ``value`` - :param str key: the name by which this ``value`` may be accessed later - :param mixed value: the value to set for this ``key`` under ``plugin`` + :param plugin: the plugin name with which to associate the ``value`` + :param key: the name by which this ``value`` may be accessed later + :param value: the value to set for this ``key`` under ``plugin`` :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error The ``value`` can be any of a range of types; it need not be a string. @@ -813,11 +858,11 @@ def set_plugin_value(self, plugin, key, value): finally: self.ssession.remove() - def delete_plugin_value(self, plugin, key): + def delete_plugin_value(self, plugin: str, key: str) -> None: """Delete a value from the key-value store for ``plugin``. - :param str plugin: the plugin name whose values to modify - :param str key: the name of the value to delete + :param plugin: the plugin name whose values to modify + :param key: the name of the value to delete :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error .. seealso:: @@ -845,13 +890,18 @@ def delete_plugin_value(self, plugin, key): finally: self.ssession.remove() - def get_plugin_value(self, plugin, key, default=None): + def get_plugin_value( + self, + plugin: str, + key: str, + default: typing.Optional[typing.Any] = None, + ) -> typing.Optional[typing.Any]: """Get a value from the key-value store for ``plugin``. - :param str plugin: the plugin name whose values to access - :param str key: the name by which the desired value was saved - :param mixed default: value to return if ``key`` does not have a value - set (optional) + :param plugin: the plugin name whose values to access + :param key: the name by which the desired value was saved + :param default: value to return if ``key`` does not have a value set + (optional) :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error .. versionadded:: 7.0 @@ -885,10 +935,10 @@ def get_plugin_value(self, plugin, key, default=None): finally: self.ssession.remove() - def forget_plugin(self, plugin): + def forget_plugin(self, plugin: str) -> None: """Remove all of a plugin's stored values. - :param str plugin: the name of the plugin for which to delete values + :param plugin: the name of the plugin for which to delete values :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error .. important:: @@ -909,13 +959,18 @@ def forget_plugin(self, plugin): # NICK AND CHANNEL FUNCTIONS - def get_nick_or_channel_value(self, name, key, default=None): + def get_nick_or_channel_value( + self, + name: str, + key: str, + default=None + ) -> typing.Optional[typing.Any]: """Get a value from the key-value store for ``name``. - :param str name: nick or channel whose values to access - :param str key: the name by which the desired value was saved - :param mixed default: value to return if ``key`` does not have a value - set (optional) + :param name: nick or channel whose values to access + :param key: the name by which the desired value was saved + :param default: value to return if ``key`` does not have a value set + (optional) :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error .. versionadded:: 7.0 @@ -934,17 +989,25 @@ def get_nick_or_channel_value(self, name, key, default=None): :meth:`get_channel_value`. """ - name = Identifier(name) - if name.is_nick(): - return self.get_nick_value(name, key, default) + if not isinstance(name, Identifier): + identifier = self.make_identifier(name) + else: + identifier = typing.cast(Identifier, name) + + if identifier.is_nick(): + return self.get_nick_value(identifier, key, default) else: - return self.get_channel_value(name, key, default) + return self.get_channel_value(identifier, key, default) - def get_preferred_value(self, names, key): + def get_preferred_value( + self, + names: typing.Iterable[str], + key: str, + ) -> typing.Optional[typing.Any]: """Get a value for the first name which has it set. - :param list names: a list of channel names and/or nicknames - :param str key: the name by which the desired value was saved + :param names: a list of channel names and/or nicknames + :param key: the name by which the desired value was saved :return: the value for ``key`` from the first ``name`` which has it set, or ``None`` if none of the ``names`` has it set :raise ~sqlalchemy.exc.SQLAlchemyError: if there is a database error @@ -965,3 +1028,6 @@ def get_preferred_value(self, names, key): value = self.get_nick_or_channel_value(name, key) if value is not None: return value + + # Explicit return for type check + return None diff --git a/sopel/irc/__init__.py b/sopel/irc/__init__.py index 1fd3878027..e4794d6487 100644 --- a/sopel/irc/__init__.py +++ b/sopel/irc/__init__.py @@ -33,6 +33,7 @@ from typing import Optional from sopel import tools, trigger +from sopel.tools import identifiers from .backends import AsynchatBackend, SSLAsynchatBackend from .isupport import ISupport from .utils import CapReq, safe @@ -47,11 +48,11 @@ class AbstractBot(abc.ABC): """Abstract definition of Sopel's interface.""" def __init__(self, settings): # private properties: access as read-only properties - self._nick = tools.Identifier(settings.core.nick) self._user = settings.core.user self._name = settings.core.name self._isupport = ISupport() self._myinfo = None + self._nick = self.make_identifier(settings.core.nick) self.backend = None """IRC Connection Backend.""" @@ -123,6 +124,16 @@ def hostmask(self) -> Optional[str]: # Utility + def make_identifier(self, name: str) -> identifiers.Identifier: + """Instantiate an Identifier using the bot's context.""" + casemapping = { + 'ascii': identifiers.ascii_lower, + 'rfc1459': identifiers.rfc1459_lower, + 'rfc1459-strict': identifiers.rfc1459_strict_lower, + }.get(self.isupport.get('CASEMAPPING'), identifiers.rfc1459_lower) + + return identifiers.Identifier(name, casemapping=casemapping) + def safe_text_length(self, recipient: str) -> int: """Estimate a safe text length for an IRC message. @@ -254,6 +265,7 @@ def on_message(self, message): self.nick, message, url_schemes=self.settings.core.auto_url_schemes, + identifier_factory=self.make_identifier, ) if all(cap not in self.enabled_capabilities for cap in ['account-tag', 'extended-join']): pretrigger.tags.pop('account', None) @@ -299,6 +311,7 @@ def on_message_sent(self, raw): self.nick, ":{0}!{1}@{2} {3}".format(self.nick, self.user, host, raw), url_schemes=self.settings.core.auto_url_schemes, + identifier_factory=self.make_identifier, ) self.dispatch(pretrigger) @@ -330,12 +343,21 @@ def on_error(self): self.last_error_timestamp = datetime.utcnow() self.error_count = self.error_count + 1 - def change_current_nick(self, new_nick): + def rebuild_nick(self) -> None: + """Rebuild nick as a new identifier. + + This method exists to update the casemapping rules for the + :class:`~sopel.tools.identifiers.Identifier` that represents the bot's + nick, e.g. after ISUPPORT info is received. + """ + self._nick = self.make_identifier(str(self._nick)) + + def change_current_nick(self, new_nick: str) -> None: """Change the current nick without configuration modification. :param str new_nick: new nick to be used by the bot """ - self._nick = tools.Identifier(new_nick) + self._nick = self.make_identifier(new_nick) LOGGER.debug('Sending nick "%s"', self.nick) self.backend.send_nick(self.nick) @@ -670,7 +692,7 @@ def say(self, text, recipient, max_messages=1, truncation='', trailing=''): flood_penalty_ratio = self.settings.core.flood_penalty_ratio with self.sending: - recipient_id = tools.Identifier(recipient) + recipient_id = self.make_identifier(recipient) recipient_stack = self.stack.setdefault(recipient_id, { 'messages': [], 'flood_left': flood_burst_lines, diff --git a/sopel/modules/adminchannel.py b/sopel/modules/adminchannel.py index c9eef9a0ab..d0fc247177 100644 --- a/sopel/modules/adminchannel.py +++ b/sopel/modules/adminchannel.py @@ -10,7 +10,7 @@ import re -from sopel import formatting, plugin, tools +from sopel import formatting, plugin ERROR_MESSAGE_NOT_OP = "I'm not a channel operator!" @@ -101,7 +101,7 @@ def kick(bot, trigger): argc = len(text) if argc < 2: return - opt = tools.Identifier(text[1]) + opt = bot.make_identifier(text[1]) nick = opt channel = trigger.sender reasonidx = 2 @@ -112,7 +112,7 @@ def kick(bot, trigger): channel = opt reasonidx = 3 reason = ' '.join(text[reasonidx:]) - if nick != bot.config.core.nick: + if nick != bot.make_identifier(bot.config.core.nick): bot.kick(nick, channel, reason) @@ -164,7 +164,7 @@ def ban(bot, trigger): argc = len(text) if argc < 2: return - opt = tools.Identifier(text[1]) + opt = bot.make_identifier(text[1]) banmask = opt channel = trigger.sender if not opt.is_nick(): @@ -194,7 +194,7 @@ def unban(bot, trigger): argc = len(text) if argc < 2: return - opt = tools.Identifier(text[1]) + opt = bot.make_identifier(text[1]) banmask = opt channel = trigger.sender if not opt.is_nick(): @@ -224,7 +224,7 @@ def quiet(bot, trigger): argc = len(text) if argc < 2: return - opt = tools.Identifier(text[1]) + opt = bot.make_identifier(text[1]) quietmask = opt channel = trigger.sender if not opt.is_nick(): @@ -254,7 +254,7 @@ def unquiet(bot, trigger): argc = len(text) if argc < 2: return - opt = tools.Identifier(text[1]) + opt = bot.make_identifier(text[1]) quietmask = opt channel = trigger.sender if not opt.is_nick(): @@ -286,7 +286,7 @@ def kickban(bot, trigger): argc = len(text) if argc < 4: return - opt = tools.Identifier(text[1]) + opt = bot.make_identifier(text[1]) nick = opt mask = text[2] channel = trigger.sender diff --git a/sopel/modules/clock.py b/sopel/modules/clock.py index 262e6e5a16..74936fd06c 100644 --- a/sopel/modules/clock.py +++ b/sopel/modules/clock.py @@ -8,7 +8,7 @@ """ from __future__ import generator_stop -from sopel import plugin, tools +from sopel import plugin from sopel.tools.time import ( format_time, get_channel_timezone, @@ -60,7 +60,7 @@ def f_time(bot, trigger): # guess if the argument is a nick, a channel, or a timezone zone = None argument = argument.strip() - channel_or_nick = tools.Identifier(argument) + channel_or_nick = bot.make_identifier(argument) # first, try to get nick or channel's timezone help_prefix = bot.config.core.help_prefix diff --git a/sopel/modules/find.py b/sopel/modules/find.py index ef649f3b88..8d5ecc02bf 100644 --- a/sopel/modules/find.py +++ b/sopel/modules/find.py @@ -18,12 +18,14 @@ from sopel import plugin from sopel.formatting import bold -from sopel.tools import Identifier, SopelIdentifierMemory +from sopel.tools import SopelIdentifierMemory def setup(bot): if 'find_lines' not in bot.memory: - bot.memory['find_lines'] = SopelIdentifierMemory() + bot.memory['find_lines'] = SopelIdentifierMemory( + identifier_factory=bot.make_identifier, + ) def shutdown(bot): @@ -42,7 +44,9 @@ def collectlines(bot, trigger): """Create a temporary log of what people say""" # Add a log for the channel and nick, if there isn't already one if trigger.sender not in bot.memory['find_lines']: - bot.memory['find_lines'][trigger.sender] = SopelIdentifierMemory() + bot.memory['find_lines'][trigger.sender] = SopelIdentifierMemory( + identifier_factory=bot.make_identifier, + ) if trigger.nick not in bot.memory['find_lines'][trigger.sender]: bot.memory['find_lines'][trigger.sender][trigger.nick] = deque(maxlen=10) @@ -102,7 +106,7 @@ def quit_cleanup(bot, trigger): @plugin.unblockable def kick_cleanup(bot, trigger): """Clean up cached data when a user is kicked from a channel.""" - nick = Identifier(trigger.args[1]) + nick = bot.make_identifier(trigger.args[1]) if nick == bot.nick: # We got kicked! Nuke the whole channel. _cleanup_channel(bot, trigger.sender) @@ -153,7 +157,7 @@ def findandreplace(bot, trigger): return # Correcting other person vs self. - rnick = Identifier(trigger.group('nick') or trigger.nick) + rnick = bot.make_identifier(trigger.group('nick') or trigger.nick) # only do something if there is conversation to work with history = bot.memory['find_lines'].get(trigger.sender, {}).get(rnick, None) diff --git a/sopel/modules/invite.py b/sopel/modules/invite.py index 2aaf2da936..7e76b7f3ff 100644 --- a/sopel/modules/invite.py +++ b/sopel/modules/invite.py @@ -8,7 +8,7 @@ """ from __future__ import generator_stop -from sopel import plugin, tools +from sopel import plugin MIN_PRIV = plugin.HALFOP @@ -16,9 +16,9 @@ def invite_handler(bot, sender, user, channel): """Common control logic for invite commands received from anywhere.""" - sender = tools.Identifier(sender) - user = tools.Identifier(user) - channel = tools.Identifier(channel) + sender = bot.make_identifier(sender) + user = bot.make_identifier(user) + channel = bot.make_identifier(channel) # Sanity checks, in case someone reuses this function from outside the plugin if not sender.is_nick(): diff --git a/sopel/modules/meetbot.py b/sopel/modules/meetbot.py index be2c6ca178..4706298de2 100644 --- a/sopel/modules/meetbot.py +++ b/sopel/modules/meetbot.py @@ -524,7 +524,7 @@ def take_comment(bot, trigger): return target, message = trigger.group(2).split(None, 1) - target = tools.Identifier(target) + target = bot.make_identifier(target) if not is_meeting_running(target): bot.say("There is no active meeting in that channel.") else: diff --git a/sopel/modules/seen.py b/sopel/modules/seen.py index 675506c275..d3691694ae 100644 --- a/sopel/modules/seen.py +++ b/sopel/modules/seen.py @@ -12,7 +12,7 @@ import datetime import time -from sopel import plugin, tools +from sopel import plugin from sopel.tools.time import seconds_to_human @@ -44,7 +44,7 @@ def seen(bot, trigger): delta = seconds_to_human((trigger.time - saw).total_seconds()) msg = "I last saw " + nick - if tools.Identifier(channel) == trigger.sender: + if bot.make_identifier(channel) == trigger.sender: if action: msg += " in here {since}, doing: {nick} {action}".format( since=delta, diff --git a/sopel/modules/tell.py b/sopel/modules/tell.py index 92cba55ed3..d9bc04b242 100644 --- a/sopel/modules/tell.py +++ b/sopel/modules/tell.py @@ -16,7 +16,7 @@ import time import unicodedata -from sopel import formatting, plugin, tools +from sopel import formatting, plugin from sopel.config import types from sopel.tools.time import format_time, get_timezone @@ -186,7 +186,7 @@ def f_remind(bot, trigger): bot.reply("%s %s what?" % (verb, tellee)) return - tellee = tools.Identifier(tellee) + tellee = bot.make_identifier(tellee) if not os.path.exists(bot.tell_filename): return @@ -202,7 +202,7 @@ def f_remind(bot, trigger): bot.reply("I'm here now; you can %s me whatever you want!" % verb) return - if tellee not in (tools.Identifier(teller), bot.nick, 'me'): + if tellee not in (bot.make_identifier(teller), bot.nick, 'me'): tz = get_timezone(bot.db, bot.config, None, tellee) timenow = format_time(bot.db, bot.config, tz, tellee) with bot.memory['tell_lock']: @@ -215,7 +215,7 @@ def f_remind(bot, trigger): response = "I'll pass that on when %s is around." % tellee bot.reply(response) - elif tools.Identifier(teller) == tellee: + elif bot.make_identifier(teller) == tellee: bot.reply('You can %s yourself that.' % verb) else: bot.reply("Hey, I'm not as stupid as Monty you know!") diff --git a/sopel/modules/translate.py b/sopel/modules/translate.py index 2204abaf4e..ff06c7cc3a 100644 --- a/sopel/modules/translate.py +++ b/sopel/modules/translate.py @@ -24,7 +24,9 @@ def setup(bot): if 'mangle_lines' not in bot.memory: - bot.memory['mangle_lines'] = tools.SopelIdentifierMemory() + bot.memory['mangle_lines'] = tools.SopelIdentifierMemory( + identifier_factory=bot.make_identifier, + ) def shutdown(bot): diff --git a/sopel/modules/url.py b/sopel/modules/url.py index f98ec48fd1..fbaac13f47 100644 --- a/sopel/modules/url.py +++ b/sopel/modules/url.py @@ -130,7 +130,9 @@ def setup(bot): # Ensure last_seen_url is in memory if 'last_seen_url' not in bot.memory: - bot.memory['last_seen_url'] = tools.SopelIdentifierMemory() + bot.memory['last_seen_url'] = tools.SopelIdentifierMemory( + identifier_factory=bot.make_identifier, + ) # Initialize shortened_urls as a dict if it doesn't exist. if 'shortened_urls' not in bot.memory: diff --git a/sopel/tests/factories.py b/sopel/tests/factories.py index f8eb85537f..1d54e51ef5 100644 --- a/sopel/tests/factories.py +++ b/sopel/tests/factories.py @@ -5,6 +5,7 @@ from __future__ import generator_stop import re +from typing import Iterable, Optional from sopel import bot, config, plugins, trigger from .mocks import MockIRCBackend, MockIRCServer, MockUser @@ -18,7 +19,11 @@ class BotFactory: The :func:`~sopel.tests.pytest_plugin.botfactory` fixture can be used to instantiate this factory. """ - def preloaded(self, settings, preloads=None): + def preloaded( + self, + settings: config.Config, + preloads: Optional[Iterable[str]] = None, + ) -> bot.Sopel: """Create a bot and preload its plugins. :param settings: Sopel's configuration for testing purposes @@ -56,7 +61,7 @@ def preloaded(self, settings, preloads=None): return mockbot - def __call__(self, settings): + def __call__(self, settings: config.Config) -> bot.Sopel: obj = bot.Sopel(settings, daemon=False) obj.backend = MockIRCBackend(obj) return obj @@ -73,7 +78,7 @@ class ConfigFactory: def __init__(self, tmpdir): self.tmpdir = tmpdir - def __call__(self, name, data): + def __call__(self, name: str, data: str) -> config.Config: tmpfile = self.tmpdir.join(name) tmpfile.write(data) return config.Config(tmpfile.strpath) @@ -87,16 +92,34 @@ class TriggerFactory: The :func:`~sopel.tests.pytest_plugin.triggerfactory` fixture can be used to instantiate this factory. """ - def wrapper(self, mockbot, raw, pattern=None): + def wrapper( + self, + mockbot: bot.Sopel, + raw: str, + pattern: Optional[str] = None, + ) -> bot.SopelWrapper: trigger = self(mockbot, raw, pattern=pattern) return bot.SopelWrapper(mockbot, trigger) - def __call__(self, mockbot, raw, pattern=None): + def __call__( + self, + mockbot: bot.Sopel, + raw: str, + pattern: Optional[str] = None, + ) -> trigger.Trigger: + match = re.match(pattern or r'.*', raw) + if match is None: + raise ValueError( + 'Cannot create a Trigger without a matching pattern') + url_schemes = mockbot.settings.core.auto_url_schemes - return trigger.Trigger( - mockbot.settings, - trigger.PreTrigger(mockbot.nick, raw, url_schemes=url_schemes), - re.match(pattern or r'.*', raw)) + pretrigger = trigger.PreTrigger( + mockbot.nick, + raw, + url_schemes=url_schemes, + identifier_factory=mockbot.make_identifier, + ) + return trigger.Trigger(mockbot.settings, pretrigger, match) class IRCFactory: @@ -107,7 +130,11 @@ class IRCFactory: The :func:`~sopel.tests.pytest_plugin.ircfactory` fixture can be used to create this factory. """ - def __call__(self, mockbot, join_threads=True): + def __call__( + self, + mockbot: bot.Sopel, + join_threads: bool = True, + ) -> MockIRCServer: return MockIRCServer(mockbot, join_threads) @@ -119,5 +146,10 @@ class UserFactory: The :func:`~sopel.tests.pytest_plugin.userfactory` fixture can be used to create this factory. """ - def __call__(self, nick=None, user=None, host=None): + def __call__( + self, + nick: Optional[str] = None, + user: Optional[str] = None, + host: Optional[str] = None, + ) -> MockUser: return MockUser(nick, user, host) diff --git a/sopel/tools/__init__.py b/sopel/tools/__init__.py index f11cc00370..8217580471 100644 --- a/sopel/tools/__init__.py +++ b/sopel/tools/__init__.py @@ -23,15 +23,17 @@ import sys import threading import traceback +from typing import Callable from pkg_resources import parse_version from sopel import __version__ from ._events import events # NOQA +from .identifiers import Identifier -_channel_prefixes = ('#', '&', '+', '!') +IdentifierFactory = Callable[[str], Identifier] # Can be implementation-dependent _regex_type = type(re.compile('')) @@ -279,127 +281,6 @@ def get_sendable_message(text, max_length=400): return text, excess.lstrip() -class Identifier(str): - """A ``str`` subclass which acts appropriately for IRC identifiers. - - When used as normal ``str`` objects, case will be preserved. - However, when comparing two Identifier objects, or comparing a Identifier - object with a ``str`` object, the comparison will be case insensitive. - This case insensitivity includes the case convention conventions regarding - ``[]``, ``{}``, ``|``, ``\\``, ``^`` and ``~`` described in RFC 2812. - """ - def __new__(cls, identifier): - # According to RFC2812, identifiers have to be in the ASCII range. - # However, I think it's best to let the IRCd determine that, and we'll - # just assume unicode. It won't hurt anything, and is more internally - # consistent. And who knows, maybe there's another use case for this - # weird case convention. - s = str.__new__(cls, identifier) - s._lowered = Identifier._lower(identifier) - return s - - def lower(self): - """Get the RFC 2812-compliant lowercase version of this identifier. - - :return: RFC 2812-compliant lowercase version of the - :py:class:`Identifier` instance - :rtype: str - """ - return self._lowered - - @staticmethod - def _lower(identifier): - """Convert an identifier to lowercase per RFC 2812. - - :param str identifier: the identifier (nickname or channel) to convert - :return: RFC 2812-compliant lowercase version of ``identifier`` - :rtype: str - """ - if isinstance(identifier, Identifier): - return identifier._lowered - # The tilde replacement isn't needed for identifiers, but is for - # channels, which may be useful at some point in the future. - low = identifier.lower().replace('[', '{').replace(']', '}') - low = low.replace('\\', '|').replace('~', '^') - return low - - @staticmethod - def _lower_swapped(identifier): - """Backward-compatible version of :meth:`_lower`. - - :param str identifier: the identifier (nickname or channel) to convert - :return: RFC 2812-non-compliant lowercase version of ``identifier`` - :rtype: str - - This is what the old :meth:`_lower` function did before Sopel 7.0. It maps - ``{}``, ``[]``, ``|``, ``\\``, ``^``, and ``~`` incorrectly. - - You shouldn't use this unless you need to migrate stored values from the - previous, incorrect "lowercase" representation to the correct one. - """ - # The tilde replacement isn't needed for identifiers, but is for - # channels, which may be useful at some point in the future. - low = identifier.lower().replace('{', '[').replace('}', ']') - low = low.replace('|', '\\').replace('^', '~') - return low - - def __repr__(self): - return "%s(%r)" % ( - self.__class__.__name__, - self.__str__() - ) - - def __hash__(self): - return self._lowered.__hash__() - - def __lt__(self, other): - if isinstance(other, str): - other = Identifier._lower(other) - return str.__lt__(self._lowered, other) - - def __le__(self, other): - if isinstance(other, str): - other = Identifier._lower(other) - return str.__le__(self._lowered, other) - - def __gt__(self, other): - if isinstance(other, str): - other = Identifier._lower(other) - return str.__gt__(self._lowered, other) - - def __ge__(self, other): - if isinstance(other, str): - other = Identifier._lower(other) - return str.__ge__(self._lowered, other) - - def __eq__(self, other): - if isinstance(other, str): - other = Identifier._lower(other) - return str.__eq__(self._lowered, other) - - def __ne__(self, other): - return not (self == other) - - def is_nick(self): - """Check if the Identifier is a nickname (i.e. not a channel) - - :return: ``True`` if this :py:class:`Identifier` is a nickname; - ``False`` if it appears to be a channel - - :: - - >>> from sopel import tools - >>> ident = tools.Identifier('Sopel') - >>> ident.is_nick() - True - >>> ident = tools.Identifier('#sopel') - >>> ident.is_nick() - False - - """ - return self and not self.startswith(_channel_prefixes) - - class OutputRedirect: """Redirect the output to the terminal and a log file. @@ -604,7 +485,7 @@ class SopelIdentifierMemory(SopelMemory): """Special Sopel memory that stores ``Identifier`` as key. This is a convenient subclass of :class:`SopelMemory` that always casts its - keys as instances of :class:`Identifier`:: + keys as instances of :class:`~.identifiers.Identifier`:: >>> from sopel import tools >>> memory = tools.SopelIdentifierMemory() @@ -620,24 +501,51 @@ class SopelIdentifierMemory(SopelMemory): with both ``Identifier`` and :class:`str` objects, taking advantage of the case-insensitive behavior of ``Identifier``. + As it works with :class:`~.identifiers.Identifier`, it accepts an + identifier factory. This factory usually comes from a bot instance (see + :meth:`bot.make_identifier()`), like + in the example of a plugin setup function:: + + def setup(bot): + bot.memory['my_plugin_storage'] = SopelIdentifierMemory( + identifier_factory=bot.make_identifier, + ) + .. note:: - Internally, it will try to do ``key = tools.Identifier(key)``, which - will raise an exception if it cannot instantiate the key properly:: + Internally, it will try to do ``key = self.make_identifier(key)``, + which will raise an exception if it cannot instantiate the key + properly:: >>> memory[1] = 'error' - AttributeError: 'int' object has no attribute 'lower' + AttributeError: 'int' object has no attribute 'translate' .. versionadded:: 7.1 + + .. versionchanged:: 8.0 + + The parameter ``identifier_factory`` has been added to properly + transform ``str`` into :class:`~.identifiers.Identifier`. This factory + is stored and accessible through :attr:`make_identifier`. + """ + def __init__( + self, + *args, + identifier_factory: IdentifierFactory = Identifier, + ) -> None: + super().__init__(*args) + self.make_identifier = identifier_factory + """A factory to transform keys into identifiers.""" + def __getitem__(self, key): - return super().__getitem__(Identifier(key)) + return super().__getitem__(self.make_identifier(key)) def __contains__(self, key): - return super().__contains__(Identifier(key)) + return super().__contains__(self.make_identifier(key)) def __setitem__(self, key, value): - super().__setitem__(Identifier(key), value) + super().__setitem__(self.make_identifier(key), value) def chain_loaders(*lazy_loaders): diff --git a/sopel/tools/identifiers.py b/sopel/tools/identifiers.py new file mode 100644 index 0000000000..b9b201ebb5 --- /dev/null +++ b/sopel/tools/identifiers.py @@ -0,0 +1,270 @@ +"""Identifier tools to represent IRC names (nick or channel). + +Nick and channel are defined by their names, which are "identifiers": their +names are used to differentiate users from each others, channels from each +others. To ensure that two channels or two users are the same, their +identifiers must be processed to be compared properly. This process depends on +which RFC and how that RFC is implemented by the server: IRC being an old +protocol, different RFCs have differents version of that process: + +* :rfc:`RFC 1459 § 2.2<1459#section-2.2>`: ASCII characters, and ``[]\\`` are + mapped to ``{}|`` +* :rfc:`RFC 2812 § 2.2<2812#section-2.2>`: same as in the previous RFC, adding + ``~`` mapped to ``^`` + +Then when ISUPPORT was added, the `CASEMAPPING parameter`__ was defined so the +server can say which process to apply: + +* ``ascii``: only ``[A-Z]`` must be mapped to ``[a-z]`` (implemented by + :func:`ascii_lower`) +* ``rfc1459``: follow :rfc:`2812`; because of how it was implemented in most + server (implemented by :func:`rfc1459_lower`) +* A strict version of :rfc:`1459` also exist but it is not recommended + (implemented by :func:`rfc1459_strict_lower`) + +As a result, the :class:`Identifier` class requires a casemapping function, +which should be provided by the :class:`bot`. + +.. seealso:: + + The bot's :class:`make_identifier` method + should be used to instantiate an :class:`Identifier` to honor the + ``CASEMAPPING`` parameter. + +.. __: https://modern.ircdocs.horse/index.html#casemapping-parameter +""" +from __future__ import generator_stop + +import string +from typing import Callable + +Casemapping = Callable[[str], str] + +ASCII_TABLE = str.maketrans(string.ascii_uppercase, string.ascii_lowercase) +RFC1459_TABLE = str.maketrans( + string.ascii_uppercase + '[]\\~', + string.ascii_lowercase + '{}|^', +) +RFC1459_STRICT_TABLE = str.maketrans( + string.ascii_uppercase + '[]\\', + string.ascii_lowercase + '{}|', +) + + +def ascii_lower(text: str) -> str: + """Lower ``text`` according to the ``ascii`` value of ``CASEMAPPING``. + + In that version, only ``[A-Z]`` are to be mapped to their lowercase + equivalent (``[a-z]``). Non-ASCII characters are kept unmodified. + """ + return text.translate(ASCII_TABLE) + + +def rfc1459_lower(text: str) -> str: + """Lower ``text`` according to :rfc:`2812`. + + Similar to :func:`rfc1459_strict_lower`, but also maps ``~`` to ``^``, as + per :rfc:`RFC 2812 § 2.2<2812#section-2.2>`: + + Because of IRC's Scandinavian origin, the characters ``{}|^`` are + considered to be the lower case equivalents of the characters + ``[]\\~``, respectively. + + .. note:: + + This is an implementation of the `CASEMAPPING parameter`__ for the + value ``rfc1459``, which doesn't use :rfc:`1459` but its updated version + :rfc:`2812`. + + .. __: https://modern.ircdocs.horse/index.html#casemapping-parameter + """ + return text.translate(RFC1459_TABLE) + + +def rfc1459_strict_lower(text: str) -> str: + """Lower ``text`` according to :rfc:`1459` (strict version). + + As per :rfc:`RFC 1459 § 2.2<1459#section-2.2>`: + + Because of IRC's scandanavian origin, the characters ``{}|`` are + considered to be the lower case equivalents of the characters ``[]\\``. + + """ + return text.translate(RFC1459_STRICT_TABLE) + + +_channel_prefixes = ('#', '&', '+', '!') + + +class Identifier(str): + """A ``str`` subclass which acts appropriately for IRC identifiers. + + :param str identifier: IRC identifier + :param casemapping: a casemapping function (optional keyword argument) + :type casemapping: Callable[[:class:`str`], :class:`str`] + + When used as normal ``str`` objects, case will be preserved. + However, when comparing two Identifier objects, or comparing an Identifier + object with a ``str`` object, the comparison will be case insensitive. + + This case insensitivity uses the provided ``casemapping`` function, + following the rules for the `CASEMAPPING parameter`__ from ISUPPORT. By + default, it uses :func:`rfc1459_lower`, following + :rfc:`RFC 2812 § 2.2<2812#section-2.2>`. + + .. note:: + + To instantiate an ``Identifier`` with the appropriate ``casemapping`` + function, it is best to rely on + :meth:`bot.make_identifier`. + + .. versionchanged:: 8.0 + + The ``casemapping`` parameter has been added. + + .. __: https://modern.ircdocs.horse/index.html#casemapping-parameter + """ + def __new__( + cls, + identifier: str, + *, + casemapping: Casemapping = rfc1459_lower, + ) -> 'Identifier': + return str.__new__(cls, identifier) + + def __init__( + self, + identifier: str, + *, + casemapping: Casemapping = rfc1459_lower, + ) -> None: + super().__init__() + self.casemapping: Casemapping = casemapping + """Casemapping function to lower the identifier.""" + self._lowered = self.casemapping(identifier) + + def lower(self) -> str: + """Get the IRC-compliant lowercase version of this identifier. + + :return: IRC-compliant lowercase version used for case-insensitive + comparisons + + The behavior of this method depends on the identifier's casemapping + function, which should be selected based on the ``CASEMAPPING`` + parameter from ``ISUPPORT``. + + .. versionchanged:: 8.0 + + Now uses the :attr:`casemapping` function to lower the identifier. + + """ + return self.casemapping(self) + + @staticmethod + def _lower(identifier: str): + """Convert an identifier to lowercase per :rfc:`2812`. + + :param str identifier: the identifier (nickname or channel) to convert + :return: RFC 2812-compliant lowercase version of ``identifier`` + :rtype: str + + :meta public: + + .. versionchanged:: 8.0 + + Previously, this would lower all non-ASCII characters. It now uses + a strict implementation of the ``CASEMAPPING`` parameter. This is + now equivalent to call :func:`rfc1459_lower`. + + If the ``identifier`` is an instance of :class:`Identifier`, this + will call that identifier's :meth:`lower` method instead. + + """ + if isinstance(identifier, Identifier): + return identifier.lower() + return rfc1459_lower(identifier) + + @staticmethod + def _lower_swapped(identifier: str): + """Backward-compatible version of :meth:`_lower`. + + :param identifier: the identifier (nickname or channel) to convert + :return: RFC 2812-non-compliant lowercase version of ``identifier`` + :rtype: str + + This is what the old :meth:`_lower` function did before Sopel 7.0. It + maps ``{}``, ``[]``, ``|``, ``\\``, ``^``, and ``~`` incorrectly. + + You shouldn't use this unless you need to migrate stored values from + the previous, incorrect "lowercase" representation to the correct one. + + :meta public: + + .. versionadded: 7.0 + + This method was added to ensure migration of improperly lowercased + data: it reverts the data back to the previous lowercase rules. + + """ + # The tilde replacement isn't needed for identifiers, but is for + # channels, which may be useful at some point in the future. + # Always convert to str, to prevent using custom casemapping + low = str(identifier).lower().replace('{', '[').replace('}', ']') + low = low.replace('|', '\\').replace('^', '~') + return low + + def __repr__(self): + return "%s(%r)" % ( + self.__class__.__name__, + self.__str__() + ) + + def __hash__(self): + return self._lowered.__hash__() + + def __lt__(self, other): + if isinstance(other, str): + other = self.casemapping(other) + return str.__lt__(self._lowered, other) + + def __le__(self, other): + if isinstance(other, str): + other = self.casemapping(other) + return str.__le__(self._lowered, other) + + def __gt__(self, other): + if isinstance(other, str): + other = self.casemapping(other) + return str.__gt__(self._lowered, other) + + def __ge__(self, other): + if isinstance(other, str): + other = self.casemapping(other) + return str.__ge__(self._lowered, other) + + def __eq__(self, other): + if isinstance(other, str): + other = self.casemapping(other) + return str.__eq__(self._lowered, other) + + def __ne__(self, other): + return not (self == other) + + def is_nick(self) -> bool: + """Check if the Identifier is a nickname (i.e. not a channel) + + :return: ``True`` if this :py:class:`Identifier` is a nickname; + ``False`` if it appears to be a channel + + :: + + >>> from sopel import tools + >>> ident = tools.Identifier('Sopel') + >>> ident.is_nick() + True + >>> ident = tools.Identifier('#sopel') + >>> ident.is_nick() + False + + """ + return bool(self) and not self.startswith(_channel_prefixes) diff --git a/sopel/tools/target.py b/sopel/tools/target.py index 284a1f7c5f..31975623d8 100644 --- a/sopel/tools/target.py +++ b/sopel/tools/target.py @@ -1,9 +1,14 @@ from __future__ import generator_stop +from datetime import datetime import functools +from typing import Any, Callable, Dict, Optional, Set, Union from sopel import privileges -from sopel.tools import Identifier +from sopel.tools import identifiers + + +IdentifierFactory = Callable[[str], identifiers.Identifier] @functools.total_ordering @@ -11,7 +16,7 @@ class User: """A representation of a user Sopel is aware of. :param nick: the user's nickname - :type nick: :class:`~.tools.Identifier` + :type nick: :class:`sopel.tools.identifiers.Identifier` :param str user: the user's local username ("user" in `user@host.name`) :param str host: the user's hostname ("host.name" in `user@host.name`) """ @@ -19,19 +24,24 @@ class User: 'nick', 'user', 'host', 'channels', 'account', 'away', ) - def __init__(self, nick, user, host): - assert isinstance(nick, Identifier) + def __init__( + self, + nick: identifiers.Identifier, + user: Optional[str], + host: Optional[str], + ) -> None: + assert isinstance(nick, identifiers.Identifier) self.nick = nick """The user's nickname.""" self.user = user """The user's local username.""" self.host = host """The user's hostname.""" - self.channels = {} + self.channels: Dict[identifiers.Identifier, 'Channel'] = {} """The channels the user is in. - This maps channel name :class:`~sopel.tools.Identifier`\\s to - :class:`Channel` objects. + This maps channel name :class:`~sopel.tools.identifiers.Identifier`\\s + to :class:`Channel` objects. """ self.account = None """The IRC services account of the user. @@ -45,12 +55,12 @@ def __init__(self, nick, user, host): self.host)) """The user's full hostmask.""" - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: if not isinstance(other, User): return NotImplemented return self.nick == other.nick - def __lt__(self, other): + def __lt__(self, other: Any) -> bool: if not isinstance(other, User): return NotImplemented return self.nick < other.nick @@ -61,33 +71,46 @@ class Channel: """A representation of a channel Sopel is in. :param name: the channel name - :type name: :class:`~.tools.Identifier` + :type name: :class:`~sopel.tools.identifiers.Identifier` + :param identifier_factory: A factory to create + :class:`~sopel.tools.identifiers.Identifier`\\s """ __slots__ = ( - 'name', 'users', 'privileges', 'topic', 'modes', 'last_who', 'join_time', + 'name', + 'users', + 'privileges', + 'topic', + 'modes', + 'last_who', + 'join_time', + 'make_identifier', ) - def __init__(self, name): - assert isinstance(name, Identifier) + def __init__( + self, + name: identifiers.Identifier, + identifier_factory: IdentifierFactory = identifiers.Identifier, + ) -> None: + assert isinstance(name, identifiers.Identifier) self.name = name """The name of the channel.""" - self.users = {} + self.users: Dict[identifiers.Identifier, User] = {} """The users in the channel. - This maps nickname :class:`~sopel.tools.Identifier`\\s to :class:`User` - objects. + This maps nickname :class:`~sopel.tools.identifiers.Identifier`\\s to + :class:`User` objects. """ - self.privileges = {} + self.privileges: Dict[identifiers.Identifier, int] = {} """The permissions of the users in the channel. - This maps nickname :class:`~sopel.tools.Identifier`\\s to bitwise - integer values. This can be compared to appropriate constants from - :mod:`sopel.plugin`. + This maps nickname :class:`~sopel.tools.identifiers.Identifier`\\s to + bitwise integer values. This can be compared to appropriate constants + from :mod:`sopel.privileges`. """ self.topic = '' """The topic of the channel.""" - self.modes = {} + self.modes: Dict[str, Union[Set, str, bool]] = {} """The channel's modes. For type A modes (nick/address list), the value is a set. For type B @@ -100,21 +123,28 @@ def __init__(self, name): does not automatically populate all modes and lists. """ - self.last_who = None + self.last_who: Optional[datetime] = None """The last time a WHO was requested for the channel.""" - self.join_time = None + self.join_time: Optional[datetime] = None """The time the server acknowledged our JOIN message. Based on server-reported time if the ``server-time`` IRCv3 capability is available, otherwise the time Sopel received it. """ - def clear_user(self, nick): + self.make_identifier: IdentifierFactory = identifier_factory + """Factory to create :class:`~sopel.tools.identifiers.Identifier`. + + ``Identifier`` is used for :class:`User`'s nick, and the channel + needs to translate nicks from string to ``Identifier`` when + manipulating data associated to a user by its nickname. + """ + + def clear_user(self, nick: identifiers.Identifier) -> None: """Remove ``nick`` from this channel. :param nick: the nickname of the user to remove - :type nick: :class:`~.tools.Identifier` Called after a user leaves the channel via PART, KICK, QUIT, etc. """ @@ -123,13 +153,12 @@ def clear_user(self, nick): if user is not None: user.channels.pop(self.name, None) - def add_user(self, user, privs=0): + def add_user(self, user: User, privs: int = 0) -> None: """Add ``user`` to this channel. :param user: the new user to add - :type user: :class:`User` - :param int privs: privilege bitmask (see constants in - :mod:`sopel.plugin`) + :param privs: privilege bitmask (see constants in + :mod:`sopel.privileges`) Called when a new user JOINs the channel. """ @@ -138,12 +167,11 @@ def add_user(self, user, privs=0): self.privileges[user.nick] = privs or 0 user.channels[self.name] = self - def has_privilege(self, nick, privilege): + def has_privilege(self, nick: str, privilege: int) -> bool: """Tell if a user has a ``privilege`` level or above in this channel. - :param str nick: a user's nick in this channel - :param int privilege: privilege level to check - :rtype: bool + :param nick: a user's nick in this channel + :param privilege: privilege level to check This method checks the user's privilege level in this channel, i.e. if it has this level or higher privileges:: @@ -153,8 +181,8 @@ def has_privilege(self, nick, privilege): True The ``nick`` argument can be either a :class:`str` or a - :class:`sopel.tools.Identifier`. If the user is not in this channel, - it will be considered as not having any privilege. + :class:`sopel.tools.identifiers.Identifier`. If the user is not in this + channel, it will be considered as not having any privilege. .. seealso:: @@ -169,13 +197,12 @@ def has_privilege(self, nick, privilege): on the presence of standard modes: ``+v`` (voice) and ``+o`` (op). """ - return self.privileges.get(Identifier(nick), 0) >= privilege + return self.privileges.get(self.make_identifier(nick), 0) >= privilege - def is_oper(self, nick): + def is_oper(self, nick: str) -> bool: """Tell if a user has the OPER (operator) privilege level. - :param str nick: a user's nick in this channel - :rtype: bool + :param nick: a user's nick in this channel Unlike :meth:`has_privilege`, this method checks if the user has been explicitly granted the OPER privilege level:: @@ -201,13 +228,13 @@ def is_oper(self, nick): sensibly if only ``+v`` (voice) and ``+o`` (op) modes exist. """ - return self.privileges.get(Identifier(nick), 0) & privileges.OPER + identifier = self.make_identifier(nick) + return bool(self.privileges.get(identifier, 0) & privileges.OPER) - def is_owner(self, nick): + def is_owner(self, nick: str) -> bool: """Tell if a user has the OWNER privilege level. - :param str nick: a user's nick in this channel - :rtype: bool + :param nick: a user's nick in this channel Unlike :meth:`has_privilege`, this method checks if the user has been explicitly granted the OWNER privilege level:: @@ -233,13 +260,13 @@ def is_owner(self, nick): sensibly if only ``+v`` (voice) and ``+o`` (op) modes exist. """ - return self.privileges.get(Identifier(nick), 0) & privileges.OWNER + identifier = self.make_identifier(nick) + return bool(self.privileges.get(identifier, 0) & privileges.OWNER) - def is_admin(self, nick): + def is_admin(self, nick: str) -> bool: """Tell if a user has the ADMIN privilege level. - :param str nick: a user's nick in this channel - :rtype: bool + :param nick: a user's nick in this channel Unlike :meth:`has_privilege`, this method checks if the user has been explicitly granted the ADMIN privilege level:: @@ -265,13 +292,13 @@ def is_admin(self, nick): sensibly if only ``+v`` (voice) and ``+o`` (op) modes exist. """ - return self.privileges.get(Identifier(nick), 0) & privileges.ADMIN + identifier = self.make_identifier(nick) + return bool(self.privileges.get(identifier, 0) & privileges.ADMIN) - def is_op(self, nick): + def is_op(self, nick: str) -> bool: """Tell if a user has the OP privilege level. - :param str nick: a user's nick in this channel - :rtype: bool + :param nick: a user's nick in this channel Unlike :meth:`has_privilege`, this method checks if the user has been explicitly granted the OP privilege level:: @@ -291,13 +318,13 @@ def is_op(self, nick): True """ - return self.privileges.get(Identifier(nick), 0) & privileges.OP + identifier = self.make_identifier(nick) + return bool(self.privileges.get(identifier, 0) & privileges.OP) - def is_halfop(self, nick): + def is_halfop(self, nick: str) -> bool: """Tell if a user has the HALFOP privilege level. - :param str nick: a user's nick in this channel - :rtype: bool + :param nick: a user's nick in this channel Unlike :meth:`has_privilege`, this method checks if the user has been explicitly granted the HALFOP privilege level:: @@ -323,13 +350,13 @@ def is_halfop(self, nick): sensibly if only ``+v`` (voice) and ``+o`` (op) modes exist. """ - return self.privileges.get(Identifier(nick), 0) & privileges.HALFOP + identifier = self.make_identifier(nick) + return bool(self.privileges.get(identifier, 0) & privileges.HALFOP) - def is_voiced(self, nick): + def is_voiced(self, nick: str) -> bool: """Tell if a user has the VOICE privilege level. - :param str nick: a user's nick in this channel - :rtype: bool + :param nick: a user's nick in this channel Unlike :meth:`has_privilege`, this method checks if the user has been explicitly granted the VOICE privilege level:: @@ -350,17 +377,20 @@ def is_voiced(self, nick): True """ - return self.privileges.get(Identifier(nick), 0) & privileges.VOICE - - def rename_user(self, old, new): + identifier = self.make_identifier(nick) + return bool(self.privileges.get(identifier, 0) & privileges.VOICE) + + def rename_user( + self, + old: identifiers.Identifier, + new: identifiers.Identifier, + ): """Rename a user. :param old: the user's old nickname - :type old: :class:`~.tools.Identifier` :param new: the user's new nickname - :type new: :class:`~.tools.Identifier` - Called on NICK events. + Called on ``NICK`` events. """ if old in self.users: self.users[new] = self.users.pop(old) diff --git a/sopel/tools/time.py b/sopel/tools/time.py index 810789135a..0b9dfeff5e 100644 --- a/sopel/tools/time.py +++ b/sopel/tools/time.py @@ -67,7 +67,7 @@ def get_nick_timezone(db, nick): :param db: Bot's database handler (usually ``bot.db``) :type db: :class:`~sopel.db.SopelDB` :param nick: IRC nickname - :type nick: :class:`~sopel.tools.Identifier` + :type nick: :class:`~sopel.tools.identifiers.Identifier` :return: the timezone associated with the ``nick`` If a timezone cannot be found for ``nick``, or if it is invalid, ``None`` @@ -85,7 +85,7 @@ def get_channel_timezone(db, channel): :param db: Bot's database handler (usually ``bot.db``) :type db: :class:`~sopel.db.SopelDB` :param channel: IRC channel name - :type channel: :class:`~sopel.tools.Identifier` + :type channel: :class:`~sopel.tools.identifiers.Identifier` :return: the timezone associated with the ``channel`` If a timezone cannot be found for ``channel``, or if it is invalid, @@ -141,8 +141,7 @@ def _check(zone): if zone: tz = _check(zone) if not tz: - tz = _check( - db.get_nick_or_channel_value(zone, 'timezone')) + tz = _check(db.get_nick_or_channel_value(zone, 'timezone')) if not tz and nick: tz = _check(db.get_nick_value(nick, 'timezone')) if not tz and channel: diff --git a/sopel/trigger.py b/sopel/trigger.py index 5481b3bf08..9c9c3c80e4 100644 --- a/sopel/trigger.py +++ b/sopel/trigger.py @@ -3,9 +3,10 @@ import datetime import re +from typing import Callable, cast, Dict, Match, Optional, Sequence, Tuple -from sopel import formatting, tools -from sopel.tools import web +from sopel import config, formatting, tools +from sopel.tools import identifiers, web __all__ = [ @@ -14,6 +15,9 @@ ] +IdentifierFactory = Callable[[str], identifiers.Identifier] + + class PreTrigger: """A parsed raw message from the server. @@ -106,30 +110,40 @@ class PreTrigger: component_regex = re.compile(r'([^!]*)!?([^@]*)@?(.*)') intent_regex = re.compile('\x01(\\S+) ?(.*)\x01') - def __init__(self, own_nick, line, url_schemes=None): + def __init__( + self, + own_nick: identifiers.Identifier, + line: str, + url_schemes: Optional[Sequence] = None, + identifier_factory: IdentifierFactory = identifiers.Identifier, + ): + self.make_identifier = identifier_factory line = line.strip('\r\n') self.line = line - self.urls = tuple() + self.urls: Tuple[str, ...] = tuple() self.plain = '' # Break off IRCv3 message tags, if present - self.tags = {} + self.tags: Dict[str, Optional[str]] = {} if line.startswith('@'): tagstring, line = line.split(' ', 1) - for tag in tagstring[1:].split(';'): - tag = tag.split('=', 1) + for raw_tag in tagstring[1:].split(';'): + tag = raw_tag.split('=', 1) if len(tag) > 1: self.tags[tag[0]] = tag[1] else: self.tags[tag[0]] = None + # Client time or server time self.time = datetime.datetime.utcnow().replace( tzinfo=datetime.timezone.utc ) if 'time' in self.tags: + # ensure "time" is a string (typecheck) + tag_time = self.tags['time'] or '' try: self.time = datetime.datetime.strptime( - self.tags['time'], + tag_time, "%Y-%m-%dT%H:%M:%S.%fZ", ).replace(tzinfo=datetime.timezone.utc) except ValueError: @@ -139,6 +153,7 @@ def __init__(self, own_nick, line, url_schemes=None): # Example: line = ':Sopel!foo@bar PRIVMSG #sopel :foobar!' # print(hostmask) # Sopel!foo@bar # All lines start with ":" except PING. + self.hostmask: Optional[str] if line.startswith(':'): self.hostmask, line = line[1:].split(' ', 1) else: @@ -163,21 +178,25 @@ def __init__(self, own_nick, line, url_schemes=None): self.event = self.args[0] self.args = self.args[1:] - components = PreTrigger.component_regex.match(self.hostmask or '').groups() - self.nick, self.user, self.host = components - self.nick = tools.Identifier(self.nick) + + # The regex will always match any string, even an empty one + components_match = cast( + Match, PreTrigger.component_regex.match(self.hostmask or '')) + nick, self.user, self.host = components_match.groups() + self.nick: identifiers.Identifier = self.make_identifier(nick) # If we have arguments, the first one is the sender # Unless it's a QUIT event + target: Optional[identifiers.Identifier] = None + if self.args and self.event != 'QUIT': - target = tools.Identifier(self.args[0]) - else: - target = None + target = self.make_identifier(self.args[0]) + + # Unless we're messaging the bot directly, in which case that + # second arg will be our bot's name. + if target.lower() == own_nick.lower(): + target = self.nick - # Unless we're messaging the bot directly, in which case that second - # arg will be our bot's name. - if target and target.lower() == own_nick.lower(): - target = self.nick self.sender = target # Parse CTCP into a form consistent with IRCv3 intents @@ -230,13 +249,13 @@ class Trigger(str): sender = property(lambda self: self._pretrigger.sender) """Where the message arrived from. - :type: :class:`~.tools.Identifier` + :type: :class:`~sopel.tools.identifiers.Identifier` This will be a channel name for "regular" (channel) messages, or the nick that sent a private message. You can check if the trigger comes from a channel or a nick with its - :meth:`~sopel.tools.Identifier.is_nick` method:: + :meth:`~sopel.tools.identifiers.Identifier.is_nick` method:: if trigger.sender.is_nick(): # message sent from a private message @@ -280,7 +299,7 @@ class Trigger(str): nick = property(lambda self: self._pretrigger.nick) """The nickname who sent the message. - :type: :class:`~.tools.Identifier` + :type: :class:`~sopel.tools.identifiers.Identifier` """ host = property(lambda self: self._pretrigger.host) """The hostname of the person who sent the message. @@ -413,8 +432,22 @@ class Trigger(str): the message isn't logged in to services, this property will be ``None``. """ - def __new__(cls, config, message, match, account=None): - self = str.__new__(cls, message.args[-1] if message.args else '') + def __new__( + cls, + settings: config.Config, + message: PreTrigger, + match: Match, + account: Optional[str] = None, + ) -> 'Trigger': + return str.__new__(cls, message.args[-1] if message.args else '') + + def __init__( + self, + settings: config.Config, + message: PreTrigger, + match: Match, + account: Optional[str] = None, + ) -> None: self._account = account self._pretrigger = message self._match = match @@ -427,14 +460,12 @@ def match_host_or_nick(pattern): pattern.match('@'.join((self.nick, self.host))) ) - if config.core.owner_account: - self._owner = config.core.owner_account == self.account + if settings.core.owner_account: + self._owner = settings.core.owner_account == self.account else: - self._owner = match_host_or_nick(config.core.owner) + self._owner = match_host_or_nick(settings.core.owner) self._admin = ( self._owner or - self.account in config.core.admin_accounts or - any(match_host_or_nick(item) for item in config.core.admins) + self.account in settings.core.admin_accounts or + any(match_host_or_nick(item) for item in settings.core.admins) ) - - return self diff --git a/test/test_coretasks.py b/test/test_coretasks.py index 7805b0349d..2462aa9cf5 100644 --- a/test/test_coretasks.py +++ b/test/test_coretasks.py @@ -315,6 +315,26 @@ def test_handle_isupport(mockbot): assert 'CNOTICE' in mockbot.isupport +def test_handle_isupport_casemapping(mockbot): + # Set bot's nick to something that needs casemapping + mockbot.settings.core.nick = 'Test[a]' + mockbot._nick = mockbot.make_identifier(mockbot.settings.core.nick) + + # check default behavior (`rfc1459` casemapping) + assert mockbot.nick.lower() == 'test{a}' + assert str(mockbot.nick) == 'Test[a]' + + # now the bot "connects" to a server using `CASEMAPPING=ascii` + mockbot.on_message( + ':irc.example.com 005 Sopel ' + 'CHANTYPES=# EXCEPTS INVEX CHANMODES=eIbq,k,flj,CFLMPQScgimnprstz ' + 'CHANLIMIT=#:120 PREFIX=(ov)@+ MAXLIST=bqeI:100 MODES=4 ' + 'NETWORK=example STATUSMSG=@+ CALLERID=g CASEMAPPING=ascii ' + ':are supported by this server') + + assert mockbot.nick.lower() == 'test[a]' + + @pytest.mark.parametrize('modes', ['', 'Rw']) def test_handle_isupport_bot_mode(mockbot, modes): mockbot.config.core.modes = modes diff --git a/test/test_db.py b/test/test_db.py index bf5ba20996..1fe7e9910a 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -11,7 +11,14 @@ import pytest -from sopel.db import ChannelValues, Nicknames, NickValues, PluginValues, SopelDB +from sopel.db import ( + ChannelValues, + NickIDs, + Nicknames, + NickValues, + PluginValues, + SopelDB, +) from sopel.tools import Identifier @@ -39,40 +46,66 @@ def teardown_function(function): def test_get_nick_id(db): - session = db.ssession() - tests = [ - [None, 'embolalia', Identifier('Embolalia')], - # Ensures case conversion is handled properly - [None, '{}{}', Identifier('[]{}')], - # Unicode, just in case - [None, 'embölaliå', Identifier('EmbölaliÅ')], - ] - - for test in tests: - test[0] = db.get_nick_id(test[2], create=True) - nick_id, slug, nick = test - registered = session.query(Nicknames) \ - .filter(Nicknames.canonical == nick) \ - .all() - assert len(registered) == 1 - assert registered[0].slug == slug and registered[0].canonical == nick - - # Check that each nick ended up with a different id - assert len(set(test[0] for test in tests)) == len(tests) + """Test get_nick_id does not create NickID by default.""" + nick = Identifier('Exirel') + session = db.session() + + # Attempt to get nick ID: it is not created by default + with pytest.raises(ValueError): + db.get_nick_id(nick) + + # Create the nick ID + nick_id = db.get_nick_id(nick, create=True) + + # Check that one and only one nickname exists with that ID + nickname = session.query(Nicknames).filter( + Nicknames.nick_id == nick_id, + ).one() # will raise if not one and exactly one + assert nickname.canonical == 'Exirel' + assert nickname.slug == nick.lower() + + session.close() + + +@pytest.mark.parametrize('name, slug, variant', ( + # Check case insensitive with ASCII only + ('Embolalia', 'embolalia', 'eMBOLALIA'), + # Ensures case conversion is handled properly + ('[][]', '{}{}', '[}{]'), + # Unicode, just in case + ('EmbölaliÅ', 'embölaliÅ', 'EMBöLALIÅ'), +)) +def test_get_nick_id_casemapping(db, name, slug, variant): + """Test get_nick_id is case-insensitive through an Identifier.""" + session = db.session() + nick = Identifier(name) + + # Create the nick ID + nick_id = db.get_nick_id(nick, create=True) + + registered = session.query(Nicknames) \ + .filter(Nicknames.canonical == name) \ + .all() + assert len(registered) == 1 + assert registered[0].slug == slug + assert registered[0].canonical == name # Check that the retrieval actually is idempotent - for test in tests: - nick_id = test[0] - # no `create` this time, since the ID should already exist - new_id = db.get_nick_id(test[2]) - assert nick_id == new_id + assert nick_id == db.get_nick_id(name) # Even if the case is different - for test in tests: - nick_id = test[0] - # still no `create`, because the nick ID should already exist now - new_id = db.get_nick_id(Identifier(test[2].upper())) - assert nick_id == new_id + assert nick_id == db.get_nick_id(variant) + + # And no other nick IDs are created (even with create=True) + assert nick_id == db.get_nick_id(name, create=True) + assert nick_id == db.get_nick_id(variant, create=True) + assert 1 == session.query(NickIDs).count() + + # But a truly different name means a new nick ID + new_nick_id = db.get_nick_id(name + '_test', create=True) + assert new_nick_id != nick_id + assert 2 == session.query(NickIDs).count() + session.close() diff --git a/test/tools/test_tools_identifiers.py b/test/tools/test_tools_identifiers.py new file mode 100644 index 0000000000..358cc96050 --- /dev/null +++ b/test/tools/test_tools_identifiers.py @@ -0,0 +1,178 @@ +"""Tests for IRC Identifier""" +from __future__ import generator_stop + +import pytest + +from sopel.tools import identifiers + + +ASCII_PARAMS = ( + ('abcd', 'abcd'), + ('ABCD', 'abcd'), + ('abc[]d', 'abc[]d'), + ('abc\\d', 'abc\\d'), + ('abc~d', 'abc~d'), + ('[A]B\\C~D', '[a]b\\c~d'), + ('ÙNÏÇÔDÉ', 'ÙnÏÇÔdÉ'), +) +RFC1459_PARAMS = ( + ('abcd', 'abcd'), + ('ABCD', 'abcd'), + ('abc[]d', 'abc{}d'), + ('abc\\d', 'abc|d'), + ('abc~d', 'abc^d'), + ('[A]B\\C~D', '{a}b|c^d'), + ('ÙNÏÇÔDÉ', 'ÙnÏÇÔdÉ'), +) +RFC1459_STRICT_PARAMS = ( + ('abcd', 'abcd'), + ('ABCD', 'abcd'), + ('abc[]d', 'abc{}d'), + ('abc\\d', 'abc|d'), + ('abc~d', 'abc~d'), + ('[A]B\\C~D', '{a}b|c~d'), + ('ÙNÏÇÔDÉ', 'ÙnÏÇÔdÉ'), +) + + +@pytest.mark.parametrize('name, slug', ASCII_PARAMS) +def test_ascii_lower(name: str, slug: str): + assert identifiers.ascii_lower(name) == slug + + +@pytest.mark.parametrize('name, slug', RFC1459_PARAMS) +def test_rfc1459_lower(name: str, slug: str): + assert identifiers.rfc1459_lower(name) == slug + + +@pytest.mark.parametrize('name, slug', RFC1459_STRICT_PARAMS) +def test_rfc1459_strict_lower(name: str, slug: str): + assert identifiers.rfc1459_strict_lower(name) == slug + + +def test_identifier_repr(): + assert "Identifier('ABCD[]')" == '%r' % identifiers.Identifier('ABCD[]') + + +@pytest.mark.parametrize('name, slug, gt, lt', ( + ('abcd', 'abcd', 'abcde', 'abc'), + ('ABCD', 'abcd', 'ABCDE', 'abc'), + ('abc[]d', 'abc{}d', 'abc[]de', 'abc{}'), + ('abc\\d', 'abc|d', 'abc\\de', 'abc|'), + ('abc~d', 'abc^d', 'abc~de', 'abc^'), + ('[A]B\\C~D', '{a}b|c^d', '[A]B\\C~DE', '{a}b|c^'), +)) +def test_identifier_default_casemapping(name, slug, gt, lt): + identifier = identifiers.Identifier(name) + assert slug == identifier.lower() + assert slug == identifiers.Identifier._lower(name) + assert slug == identifiers.Identifier._lower(identifier) + assert hash(slug) == hash(identifier) + + # eq + assert identifier == slug + assert identifier == name + assert identifier == identifiers.Identifier(slug) + assert identifier == identifiers.Identifier(name) + + # not eq + assert identifier != slug + 'f' + assert identifier != name + 'f' + assert identifier != identifiers.Identifier(slug + 'f') + assert identifier != identifiers.Identifier(name + 'f') + + # gt(e) + assert identifier >= slug + assert identifier >= name + assert identifier >= lt + assert identifier > lt + assert identifier >= identifiers.Identifier(lt) + assert identifier > identifiers.Identifier(lt) + + # lt(e) + assert identifier <= slug + assert identifier <= name + assert identifier <= gt + assert identifier < gt + assert identifier <= identifiers.Identifier(gt) + assert identifier < identifiers.Identifier(gt) + + +@pytest.mark.parametrize('name, slug', ASCII_PARAMS) +def test_identifier_ascii_casemapping(name, slug): + identifier = identifiers.Identifier( + name, + casemapping=identifiers.ascii_lower, + ) + assert identifier.lower() == slug + assert hash(identifier) == hash(slug) + assert identifier == name + assert identifier == slug + assert slug == identifiers.Identifier._lower(identifier), ( + 'Always use identifier.lower()', + ) + + +@pytest.mark.parametrize('name, slug', RFC1459_PARAMS) +def test_identifier_rfc1459_casemapping(name, slug): + identifier = identifiers.Identifier( + name, + casemapping=identifiers.rfc1459_lower, + ) + assert identifier.lower() == slug + assert hash(identifier) == hash(slug) + assert identifier == name + assert identifier == slug + assert slug == identifiers.Identifier._lower(identifier), ( + 'Always use identifier.lower()', + ) + + +@pytest.mark.parametrize('name, slug', RFC1459_STRICT_PARAMS) +def test_identifier_rfc1459_strict_casemapping(name, slug): + identifier = identifiers.Identifier( + name, + casemapping=identifiers.rfc1459_strict_lower, + ) + assert identifier.lower() == slug + assert hash(identifier) == hash(slug) + assert identifier == name + assert identifier == slug + assert slug == identifiers.Identifier._lower(identifier), ( + 'Always use identifier.lower()', + ) + + +@pytest.mark.parametrize('wrong_type', ( + None, 0, 10, 3.14, object() +)) +def test_identifier_compare_invalid(wrong_type): + identifier = identifiers.Identifier('xnaas') + + # you can compare equality (or lack thereof) + assert not (identifier == wrong_type) + assert identifier != wrong_type + + with pytest.raises(TypeError): + identifier >= wrong_type + + with pytest.raises(TypeError): + identifier > wrong_type + + with pytest.raises(TypeError): + identifier <= wrong_type + + with pytest.raises(TypeError): + identifier < wrong_type + + +def test_identifier_is_nick(): + assert identifiers.Identifier('Exirel').is_nick() + + +def test_identifier_is_nick_channel(): + assert not identifiers.Identifier('#exirel').is_nick() + + +def test_identifier_is_nick_empty(): + assert not identifiers.Identifier('').is_nick()