# -*- test-case-name: omnipresence.test.test_hostmask -*-
"""Operations on IRC hostmasks."""
from collections import namedtuple
import itertools
import re
def mask_as_regex(mask):
"""Return a regex object corresponding to the IRC hostmask pattern
string *mask*."""
pattern = ''
backslash = False # was the last character a backslash?
for char in mask:
if backslash:
# The RFC doesn't state this explicitly, but the provided
# BNF syntax defines the backslash as an escape character
# only when it precedes a wildcard; otherwise, it's just a
# backslash. I don't really see this coming up much in
# practice, since the characters '*', '?', and '\' don't
# ever come up in real hostmasks, but it's best to follow
# the rules as closely as possible.
#
# Technically, we parse incorrectly on masks like '\\*',
# which should match a single backslash followed by an
# asterisk, not two backslashes followed by any sequence of
# characters, but someone who comes up with a mask like that
# doesn't deserve to use this function.
if char == '*' or char == '?':
pattern += '\\' + char
else:
pattern += '\\\\' + re.escape(char)
backslash = False
else:
if char == '\\':
backslash = True
elif char == '*':
pattern += '.*'
elif char == '?':
pattern += '.'
else:
pattern += re.escape(char)
# Anchoring the regex with '\A' and '\Z' ensures that the pattern
# matches the entire string, not just a portion.
return re.compile(r'\A' + pattern + r'\Z')
[docs]class Hostmask(namedtuple('Hostmask', ('nick', 'user', 'host'))):
"""Represents an IRC hostmask (sometimes called a message prefix) of
the form ``nick!user@host``. The *user* and *host* attributes are
optional, and default to `None` if not present."""
@classmethod
[docs] def from_string(cls, string):
"""Return a new `.Hostmask` object parsed from *string*,
according to the definition of ``<prefix>`` in
:rfc:`1459#section-2.3.1`."""
rest, _, host = string.partition('@')
nick, _, user = rest.partition('!')
return cls(nick, user or None, host or None)
[docs] def matches(self, other, case_mapping=None):
"""Check whether this hostmask matches the pattern in *other*,
which can be a `Hostmask` object or a string in the form
``"nick!user@host"``, according to the wildcard expansion rules
in :rfc:`2812#section-2.5`. A `.CaseMapping` object may
optionally be provided, in which case nicks are compared
case-insensitively according to the mapping's rules; otherwise,
nick comparisons are fully case-sensitive. Users and hosts are
always compared case-insensitively, using normal ASCII case
folding rules.
Briefly, ``*`` and ``?`` wildcards match zero or more and
exactly one non-delimiter character, respectively; a backslash
can be used to escape these special characters. Components that
equal `None` are assumed to match all possible values for that
component.
.. warning::
This method only matches wildcards in *other*, not the
`Hostmask` object it is called on::
>>> Hostmask.from_string('nick!user@host').matches('nick!*@*')
True
>>> Hostmask.from_string('nick!*@*').matches('nick!user@host')
False
"""
# pylint: disable=invalid-name
me = self
if isinstance(other, str):
other = Hostmask.from_string(other)
me, other = (
x._replace(
nick=case_mapping.lower(x.nick) if case_mapping else x.nick,
user=x.user.lower() if x.user else None,
host=x.host.lower() if x.host else None)
for x in (me, other))
for mine, theirs in itertools.izip(me, other):
if mine is None or theirs is None:
continue
if not mask_as_regex(theirs).match(mine):
return False
return True
def __str__(self):
return (self.nick +
(('!' + self.user) if self.user else '') +
(('@' + self.host) if self.host else ''))
@property
def has_wildcard(self):
"""`True` if this hostmask contains a wildcard or any `None`
components. Otherwise, `False` otherwise."""
if any(x is None for x in self):
return True
for component in self:
backslash = False # was the last character a backslash?
for char in component:
if backslash:
# Same caveat as in mask_as_regex.
backslash = False
else:
if char == '\\':
backslash = True
elif char in ('*', '?'):
return True
return False