diff --git a/README.md b/README.md index 6e7b75b..ae15510 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ pydle Python IRC library. ------------------- -pydle is a compact, flexible and standards-abiding IRC library for Python 3.5+. +pydle is a compact, flexible and standards-abiding IRC library for Python 3.5 through 3.9. Features -------- @@ -26,6 +26,9 @@ Basic Usage From there, you can `import pydle` and subclass `pydle.Client` for your own functionality. +> To enable SSL support, install the `sasl` extra. +> `pip install pydle[sasl]` + Setting a nickname and starting a connection over TLS: ```python import pydle @@ -103,7 +106,7 @@ class Client(MyBase): **Q: How do I...?** -Stop! Read the [documentation](http://pydle.readthedocs.org) first. If you're still in need of support, join us on IRC! We hang at `#kochira` on `irc.freenode.net`. If someone is around, they'll most likely gladly help you. +Stop! Read the [documentation](http://pydle.readthedocs.org) first. If you're still in need of support, join us on IRC! We hang at `#pydle` on `irc.libera.chat`. If someone is around, they'll most likely gladly help you. License ------- diff --git a/docs/features/overview.rst b/docs/features/overview.rst index dc9a28c..ea99890 100644 --- a/docs/features/overview.rst +++ b/docs/features/overview.rst @@ -53,7 +53,7 @@ In addition, it registers the `pydle.Client.on_ctcp(from, query, contents)` hook request, and a per-type hook in the form of `pydle.Client.on_ctcp_(from, contents)`, which allows you to act upon CTCP requests of type `type`. `type` will always be lowercased. A few examples of `type` can be: `action`, `time`, `version`. -Finally, it registers the `pydle.Client.on_ctcp_reply(from, queyr, contents)` hook, which acts similar to the above hook, +Finally, it registers the `pydle.Client.on_ctcp_reply(from, query, contents)` hook, which acts similar to the above hook, except it is triggered when the client receives a CTCP response. It also registers `pydle.Client.on_ctcp__reply`, which works similar to the per-type hook described above. @@ -252,4 +252,4 @@ Support For `RPL_WHOIS_HOST` messages, this allows pydle to expose an IRC users real IP address and host, if the bot has access to that information. This information will fill in the `real_ip_address` and `real_hostname` fields -of an :class:`pydle.Client.whois()` response. \ No newline at end of file +of an :class:`pydle.Client.whois()` response. diff --git a/docs/intro.rst b/docs/intro.rst index c69fe1d..976aced 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -4,7 +4,7 @@ Introduction to pydle What is pydle? -------------- -pydle is an IRC library for Python 3.5 and up. +pydle is an IRC library for Python 3.5 through 3.9. Although old and dated on some fronts, IRC is still used by a variety of communities as the real-time communication method of choice, and the most popular IRC networks can still count on tens of thousands of users at any point during the day. @@ -35,7 +35,7 @@ All dependencies can be installed using the standard package manager for Python, Compatibility ------------- -pydle works in any interpreter that implements Python 3.5 or higher. Although mainly tested in CPython_, the standard Python implementation, +pydle works in any interpreter that implements Python 3.5-3.9. Although mainly tested in CPython_, the standard Python implementation, there is no reason why pydle itself should not work in alternative implementations like PyPy_, as long as they support the Python 3.5 language requirements. .. _CPython: https://python.org diff --git a/docs/usage.rst b/docs/usage.rst index ac97b8f..5236816 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -189,7 +189,7 @@ Fortunately, pydle utilizes asyncio coroutines_ which allow you to handle a bloc while still retaining the benefits of asynchronous program flow. Coroutines allow pydle to be notified when a blocking operation is done, and then resume execution of the calling function appropriately. That way, blocking operations do not block the entire program flow. -In order for a function to be declared as a coroutine, it has to be declared as an ``async def`` function or decorated with the :meth:`asyncio.coroutine` decorator. +In order for a function to be declared as a coroutine, it has to be declared as an ``async def`` function. It can then call functions that would normally block using Python's ``await`` operator. Since a function that calls a blocking function is itself blocking too, it has to be declared a coroutine as well. @@ -220,7 +220,7 @@ the act of WHOISing will not block the entire program flow of the client. self.join('#kochira') - await def is_admin(self, nickname): + async def is_admin(self, nickname): """ Check whether or not a user has administrative rights for this bot. This is a blocking function: use a coroutine to call it. diff --git a/pydle/__init__.py b/pydle/__init__.py index b92c8d3..2ead20d 100644 --- a/pydle/__init__.py +++ b/pydle/__init__.py @@ -1,11 +1,11 @@ +# noinspection PyUnresolvedReferences +from asyncio import coroutine, Future +from functools import cmp_to_key from . import connection, protocol, client, features - from .client import Error, NotInChannel, AlreadyInChannel, BasicClient, ClientPool from .features.ircv3.cap import NEGOTIATING as CAPABILITY_NEGOTIATING, FAILED as CAPABILITY_FAILED, \ NEGOTIATED as CAPABILITY_NEGOTIATED -# noinspection PyUnresolvedReferences -from asyncio import coroutine, Future __name__ = 'pydle' __version__ = '0.9.4rc1' @@ -15,12 +15,11 @@ def featurize(*features): """ Put features into proper MRO order. """ - from functools import cmp_to_key def compare_subclass(left, right): if issubclass(left, right): return -1 - elif issubclass(right, left): + if issubclass(right, left): return 1 return 0 @@ -32,9 +31,9 @@ def compare_subclass(left, right): class Client(featurize(*features.ALL)): """ A fully featured IRC client. """ - pass + ... class MinimalClient(featurize(*features.LITE)): """ A cut-down, less-featured IRC client. """ - pass + ... diff --git a/pydle/client.py b/pydle/client.py index 995d8e6..1e867e6 100644 --- a/pydle/client.py +++ b/pydle/client.py @@ -3,9 +3,10 @@ import asyncio import logging from asyncio import new_event_loop, gather, get_event_loop, sleep - -from . import connection, protocol import warnings +from . import connection, protocol +import inspect +import functools __all__ = ['Error', 'AlreadyInChannel', 'NotInChannel', 'BasicClient', 'ClientPool'] DEFAULT_NICKNAME = '' @@ -13,7 +14,7 @@ class Error(Exception): """ Base class for all pydle errors. """ - pass + ... class NotInChannel(Error): @@ -56,10 +57,10 @@ def PING_TIMEOUT(self, value): ) self.READ_TIMEOUT = value - def __init__(self, nickname, fallback_nicknames=[], username=None, realname=None, + def __init__(self, nickname, fallback_nicknames=None, username=None, realname=None, eventloop=None, **kwargs): """ Create a client. """ - self._nicknames = [nickname] + fallback_nicknames + self._nicknames = [nickname] + (fallback_nicknames or []) self.username = username or nickname.lower() self.realname = realname or nickname if eventloop: @@ -149,12 +150,12 @@ async def _disconnect(self, expected): if expected and self.own_eventloop: self.connection.stop() - async def _connect(self, hostname, port, reconnect=False, channels=[], + async def _connect(self, hostname, port, reconnect=False, channels=None, encoding=protocol.DEFAULT_ENCODING, source_address=None): """ Connect to IRC host. """ # Create connection if we can't reuse it. if not reconnect or not self.connection: - self._autojoin_channels = channels + self._autojoin_channels = channels or [] self.connection = connection.Connection(hostname, port, source_address=source_address, eventloop=self.eventloop) self.encoding = encoding @@ -167,10 +168,8 @@ def _reconnect_delay(self): if self.RECONNECT_ON_ERROR and self.RECONNECT_DELAYED: if self._reconnect_attempts >= len(self.RECONNECT_DELAYS): return self.RECONNECT_DELAYS[-1] - else: - return self.RECONNECT_DELAYS[self._reconnect_attempts] - else: - return 0 + return self.RECONNECT_DELAYS[self._reconnect_attempts] + return 0 ## Internal database management. @@ -197,21 +196,21 @@ def _create_user(self, nickname): 'hostname': None } - def _sync_user(self, nick, metadata): + async def _sync_user(self, nick, metadata): # Create user in database. if nick not in self.users: - self._create_user(nick) + await self._create_user(nick) if nick not in self.users: return self.users[nick].update(metadata) - def _rename_user(self, user, new): + async def _rename_user(self, user, new): if user in self.users: self.users[new] = self.users[user] self.users[new]['nickname'] = new del self.users[user] else: - self._create_user(new) + await self._create_user(new) if new not in self.users: return @@ -297,8 +296,7 @@ def server_tag(self): tag = host return tag - else: - return None + return None ## IRC API. @@ -372,7 +370,7 @@ async def handle_forever(self): try: await self.rawmsg("PING", self.server_tag) data = await self.connection.recv(timeout=self.READ_TIMEOUT) - except (asyncio.TimeoutError, ConnectionResetError) as e: + except (asyncio.TimeoutError, ConnectionResetError): data = None if not data: @@ -430,7 +428,7 @@ async def on_unknown(self, message): async def _ignored(self, message): """ Ignore message. """ - pass + ... def __getattr__(self, attr): """ Return on_unknown or _ignored for unknown handlers, depending on the invocation type. """ @@ -441,13 +439,30 @@ def __getattr__(self, attr): # In that case, return the method that logs and possibly acts on unknown messages. return self.on_unknown # Are we in an existing handler calling super()? - else: - # Just ignore it, then. - return self._ignored + # Just ignore it, then. + return self._ignored # This isn't a handler, just raise an error. raise AttributeError(attr) + # Bonus features + def event(self, func): + """ + Registers the specified `func` to handle events of the same name. + + The func will always be called with, at least, the bot's `self` instance. + + Returns decorated func, unmodified. + """ + if not func.__name__.startswith("on_"): + raise NameError("Event handlers must start with 'on_'.") + + if not inspect.iscoroutinefunction(func): + raise AssertionError("Wrapped function {!r} must be an `async def` function.".format(func)) + setattr(self, func.__name__, functools.partial(func, self)) + + return func + class ClientPool: """ A pool of clients that are ran and handled in parallel. """ diff --git a/pydle/features/__init__.py b/pydle/features/__init__.py index 5f1c6a6..c87f883 100644 --- a/pydle/features/__init__.py +++ b/pydle/features/__init__.py @@ -7,6 +7,8 @@ from .isupport import ISUPPORTSupport from .whox import WHOXSupport from .ircv3 import IRCv3Support, IRCv3_1Support, IRCv3_2Support +from .rpl_whoishost import RplWhoisHostSupport -ALL = [ IRCv3Support, WHOXSupport, ISUPPORTSupport, CTCPSupport, AccountSupport, TLSSupport, RFC1459Support ] -LITE = [ WHOXSupport, ISUPPORTSupport, CTCPSupport, TLSSupport, RFC1459Support ] +ALL = [IRCv3Support, WHOXSupport, ISUPPORTSupport, CTCPSupport, AccountSupport, TLSSupport, RFC1459Support, + RplWhoisHostSupport] +LITE = [WHOXSupport, ISUPPORTSupport, CTCPSupport, TLSSupport, RFC1459Support] diff --git a/pydle/features/account.py b/pydle/features/account.py index e181561..2917061 100644 --- a/pydle/features/account.py +++ b/pydle/features/account.py @@ -1,7 +1,7 @@ ## account.py # Account system support. from pydle.features import rfc1459 -import asyncio + class AccountSupport(rfc1459.RFC1459Support): @@ -15,16 +15,17 @@ def _create_user(self, nickname): 'identified': False }) - def _rename_user(self, user, new): - super()._rename_user(user, new) + async def _rename_user(self, user, new): + await super()._rename_user(user, new) # Unset account info to be certain until we get a new response. - self._sync_user(new, {'account': None, 'identified': False}) - self.whois(new) + await self._sync_user(new, {'account': None, 'identified': False}) + await self.whois(new) ## IRC API. - @asyncio.coroutine - def whois(self, nickname): - info = yield from super().whois(nickname) + async def whois(self, nickname): + info = await super().whois(nickname) + if info is None: + return info info.setdefault('account', None) info.setdefault('identified', False) return info @@ -39,7 +40,7 @@ async def on_raw_307(self, message): } if nickname in self.users: - self._sync_user(nickname, info) + await self._sync_user(nickname, info) if nickname in self._pending['whois']: self._whois_info[nickname].update(info) @@ -52,6 +53,6 @@ async def on_raw_330(self, message): } if nickname in self.users: - self._sync_user(nickname, info) + await self._sync_user(nickname, info) if nickname in self._pending['whois']: self._whois_info[nickname].update(info) diff --git a/pydle/features/ctcp.py b/pydle/features/ctcp.py index 9f4e594..746a746 100644 --- a/pydle/features/ctcp.py +++ b/pydle/features/ctcp.py @@ -1,9 +1,10 @@ ## ctcp.py # Client-to-Client-Protocol (CTCP) support. +import pydle import pydle.protocol from pydle.features import rfc1459 from pydle import client -__all__ = [ 'CTCPSupport' ] +__all__ = ['CTCPSupport'] CTCP_DELIMITER = '\x01' @@ -21,7 +22,7 @@ async def on_ctcp(self, by, target, what, contents): Client subclasses can override on_ctcp_ to be called when receiving a message of that specific CTCP type, in addition to this callback. """ - pass + ... async def on_ctcp_reply(self, by, target, what, response): """ @@ -29,16 +30,14 @@ async def on_ctcp_reply(self, by, target, what, response): Client subclasses can override on_ctcp__reply to be called when receiving a reply of that specific CTCP type, in addition to this callback. """ - pass + ... async def on_ctcp_version(self, by, target, contents): """ Built-in CTCP version as some networks seem to require it. """ - import pydle version = '{name} v{ver}'.format(name=pydle.__name__, ver=pydle.__version__) await self.ctcp_reply(by, 'VERSION', version) - ## IRC API. async def ctcp(self, target, query, contents=None): @@ -55,7 +54,6 @@ async def ctcp_reply(self, target, query, response): await self.notice(target, construct_ctcp(query, response)) - ## Handler overrides. async def on_raw_privmsg(self, message): @@ -64,7 +62,7 @@ async def on_raw_privmsg(self, message): target, msg = message.params if is_ctcp(msg): - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) type, contents = parse_ctcp(msg) # Find dedicated handler if it exists. @@ -76,14 +74,13 @@ async def on_raw_privmsg(self, message): else: await super().on_raw_privmsg(message) - async def on_raw_notice(self, message): """ Modify NOTICE to redirect CTCP messages. """ nick, metadata = self._parse_user(message.source) target, msg = message.params if is_ctcp(msg): - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) _type, response = parse_ctcp(msg) # Find dedicated handler if it exists. @@ -102,6 +99,7 @@ def is_ctcp(message): """ Check if message follows the CTCP format. """ return message.startswith(CTCP_DELIMITER) and message.endswith(CTCP_DELIMITER) + def construct_ctcp(*parts): """ Construct CTCP message. """ message = ' '.join(parts) @@ -111,6 +109,7 @@ def construct_ctcp(*parts): message = message.replace(CTCP_ESCAPE_CHAR, CTCP_ESCAPE_CHAR + CTCP_ESCAPE_CHAR) return CTCP_DELIMITER + message + CTCP_DELIMITER + def parse_ctcp(query): """ Strip and de-quote CTCP messages. """ query = query.strip(CTCP_DELIMITER) diff --git a/pydle/features/ircv3/cap.py b/pydle/features/ircv3/cap.py index 7bae1d5..06ba04f 100644 --- a/pydle/features/ircv3/cap.py +++ b/pydle/features/ircv3/cap.py @@ -4,7 +4,7 @@ import pydle.protocol from pydle.features import rfc1459 -__all__ = [ 'CapabilityNegotiationSupport', 'NEGOTIATED', 'NEGOTIATING', 'FAILED' ] +__all__ = ['CapabilityNegotiationSupport', 'NEGOTIATED', 'NEGOTIATING', 'FAILED'] DISABLED_PREFIX = '-' @@ -49,7 +49,6 @@ def _capability_normalize(self, cap): return cap, value - ## API. async def _capability_negotiated(self, capab): @@ -59,7 +58,6 @@ async def _capability_negotiated(self, capab): if not self._capabilities_requested and not self._capabilities_negotiating: await self.rawmsg('CAP', 'END') - ## Message handlers. async def on_raw_cap(self, message): @@ -107,7 +105,7 @@ async def on_raw_cap_ls(self, params): async def on_raw_cap_list(self, params): """ Update active capabilities. """ - self._capabilities = { capab: False for capab in self._capabilities } + self._capabilities = {capab: False for capab in self._capabilities} for capab in params[0].split(): capab, value = self._capability_normalize(capab) diff --git a/pydle/features/ircv3/ircv3_1.py b/pydle/features/ircv3/ircv3_1.py index ad47a0e..7ea6364 100644 --- a/pydle/features/ircv3/ircv3_1.py +++ b/pydle/features/ircv3/ircv3_1.py @@ -4,25 +4,26 @@ from . import cap from . import sasl -__all__ = [ 'IRCv3_1Support' ] +__all__ = ['IRCv3_1Support'] NO_ACCOUNT = '*' + class IRCv3_1Support(sasl.SASLSupport, cap.CapabilityNegotiationSupport, account.AccountSupport, tls.TLSSupport): """ Support for IRCv3.1's base and optional extensions. """ - def _rename_user(self, user, new): + async def _rename_user(self, user, new): # If the server supports account-notify, we will be told about the registration status changing. # As such, we can skip the song and dance pydle.features.account does. if self._capabilities.get('account-notify', False): account = self.users.get(user, {}).get('account', None) identified = self.users.get(user, {}).get('identified', False) - super()._rename_user(user, new) + await super()._rename_user(user, new) if self._capabilities.get('account-notify', False): - self._sync_user(new, {'account': account, 'identified': identified}) + await self._sync_user(new, {'account': account, 'identified': identified}) ## IRC callbacks. @@ -46,7 +47,6 @@ async def on_capability_tls_available(self, value): """ We never need to request this explicitly. """ return False - ## Message handlers. async def on_raw_account(self, message): @@ -60,11 +60,11 @@ async def on_raw_account(self, message): if nick not in self.users: return - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) if account == NO_ACCOUNT: - self._sync_user(nick, { 'account': None, 'identified': False }) + await self._sync_user(nick, {'account': None, 'identified': False}) else: - self._sync_user(nick, { 'account': account, 'identified': True }) + await self._sync_user(nick, {'account': account, 'identified': True}) async def on_raw_away(self, message): """ Process AWAY messages. """ @@ -75,7 +75,7 @@ async def on_raw_away(self, message): if nick not in self.users: return - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) self.users[nick]['away'] = len(message.params) > 0 self.users[nick]['away_message'] = message.params[0] if len(message.params) > 0 else None @@ -85,7 +85,7 @@ async def on_raw_join(self, message): nick, metadata = self._parse_user(message.source) channels, account, realname = message.params - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) # Emit a fake join message. fakemsg = self._create_message('JOIN', channels, source=message.source) diff --git a/pydle/features/ircv3/ircv3_2.py b/pydle/features/ircv3/ircv3_2.py index 35518b8..f0c266a 100644 --- a/pydle/features/ircv3/ircv3_2.py +++ b/pydle/features/ircv3/ircv3_2.py @@ -5,7 +5,7 @@ from . import monitor from . import metadata -__all__ = [ 'IRCv3_2Support' ] +__all__ = ['IRCv3_2Support'] class IRCv3_2Support(metadata.MetadataSupport, monitor.MonitoringSupport, tags.TaggedMessageSupport, ircv3_1.IRCv3_1Support): @@ -20,7 +20,7 @@ async def on_capability_account_tag_available(self, value): async def on_capability_cap_notify_available(self, value): """ Take note of new or removed capabilities. """ return True - + async def on_capability_chghost_available(self, value): """ Server reply to indicate a user we are in a common channel with changed user and/or host. """ return True @@ -45,8 +45,6 @@ async def on_isupport_uhnames(self, value): """ Let the server know that we support UHNAMES using the old ISUPPORT method, for legacy support. """ await self.rawmsg('PROTOCTL', 'UHNAMES') - - ## API overrides. async def message(self, target, message): @@ -67,7 +65,6 @@ async def notice(self, target, message): else: await self.on_private_notice(target, self.nickname, message) - ## Message handlers. async def on_raw(self, message): @@ -78,7 +75,7 @@ async def on_raw(self, message): 'identified': True, 'account': message.tags['account'] } - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) await super().on_raw(message) async def on_raw_chghost(self, message): @@ -95,4 +92,4 @@ async def on_raw_chghost(self, message): 'username': message.params[0], 'hostname': message.params[1] } - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) diff --git a/pydle/features/ircv3/ircv3_3.py b/pydle/features/ircv3/ircv3_3.py index 62b9201..7a48b71 100644 --- a/pydle/features/ircv3/ircv3_3.py +++ b/pydle/features/ircv3/ircv3_3.py @@ -2,7 +2,7 @@ # IRCv3.3 support (in progress). from . import ircv3_2 -__all__ = [ 'IRCv3_3Support' ] +__all__ = ['IRCv3_3Support'] class IRCv3_3Support(ircv3_2.IRCv3_2Support): diff --git a/pydle/features/ircv3/metadata.py b/pydle/features/ircv3/metadata.py index ac99b4e..a194b2b 100644 --- a/pydle/features/ircv3/metadata.py +++ b/pydle/features/ircv3/metadata.py @@ -41,13 +41,11 @@ async def unset_metadata(self, target, key): async def clear_metadata(self, target): await self.rawmsg('METADATA', target, 'CLEAR') - ## Callbacks. async def on_metadata(self, target, key, value, visibility=None): pass - ## Message handlers. async def on_capability_metadata_notify_available(self, value): @@ -61,7 +59,7 @@ async def on_raw_metadata(self, message): visibility = None if target in self.users: - self._sync_user(target, targetmeta) + await self._sync_user(target, targetmeta) await self.on_metadata(target, key, value, visibility=visibility) async def on_raw_760(self, message): @@ -72,7 +70,7 @@ async def on_raw_760(self, message): if target not in self._pending['whois']: return if target in self.users: - self._sync_user(target, targetmeta) + await self._sync_user(target, targetmeta) self._whois_info[target].setdefault('metadata', {}) self._whois_info[target]['metadata'][key] = value @@ -86,7 +84,7 @@ async def on_raw_761(self, message): if target not in self._pending['metadata']: return if target in self.users: - self._sync_user(target, targetmeta) + await self._sync_user(target, targetmeta) self._metadata_info[target][key] = value @@ -103,7 +101,7 @@ async def on_raw_762(self, message): async def on_raw_764(self, message): """ Metadata limit reached. """ - pass + ... async def on_raw_765(self, message): """ Invalid metadata target. """ @@ -112,7 +110,7 @@ async def on_raw_765(self, message): if target not in self._pending['metadata']: return if target in self.users: - self._sync_user(target, targetmeta) + await self._sync_user(target, targetmeta) self._metadata_queue.remove(target) del self._metadata_info[target] @@ -122,16 +120,16 @@ async def on_raw_765(self, message): async def on_raw_766(self, message): """ Unknown metadata key. """ - pass + ... async def on_raw_767(self, message): """ Invalid metadata key. """ - pass + ... async def on_raw_768(self, message): """ Metadata key not set. """ - pass + ... async def on_raw_769(self, message): """ Metadata permission denied. """ - pass + ... diff --git a/pydle/features/ircv3/monitor.py b/pydle/features/ircv3/monitor.py index 72de3cc..798b6d2 100644 --- a/pydle/features/ircv3/monitor.py +++ b/pydle/features/ircv3/monitor.py @@ -1,9 +1,9 @@ ## monitor.py # Online status monitoring support. -from . import cap +from .. import isupport -class MonitoringSupport(cap.CapabilityNegotiationSupport): +class MonitoringSupport(isupport.ISUPPORTSupport): """ Support for monitoring the online/offline status of certain targets. """ ## Internals. @@ -15,7 +15,7 @@ def _reset_attributes(self): def _destroy_user(self, nickname, channel=None, monitor_override=False): # Override _destroy_user to not remove user if they are being monitored by us. if channel: - channels = [ self.channels[channel] ] + channels = [self.channels[channel]] else: channels = self.channels.values() @@ -33,42 +33,37 @@ def _destroy_user(self, nickname, channel=None, monitor_override=False): if (monitor_override or not self.is_monitoring(nickname)) and (not channel or not any(nickname in ch['users'] for ch in self.channels.values())): del self.users[nickname] - ## API. - def monitor(self, target): + async def monitor(self, target): """ Start monitoring the online status of a user. Returns whether or not the server supports monitoring. """ - if 'monitor-notify' in self._capabilities and not self.is_monitoring(target): - yield from self.rawmsg('MONITOR', '+', target) + if 'MONITOR' in self._isupport and not self.is_monitoring(target): + await self.rawmsg('MONITOR', '+', target) self._monitoring.add(target) return True - else: - return False + return False - def unmonitor(self, target): + async def unmonitor(self, target): """ Stop monitoring the online status of a user. Returns whether or not the server supports monitoring. """ - if 'monitor-notify' in self._capabilities and self.is_monitoring(target): - yield from self.rawmsg('MONITOR', '-', target) + if 'MONITOR' in self._isupport and self.is_monitoring(target): + await self.rawmsg('MONITOR', '-', target) self._monitoring.remove(target) return True - else: - return False + return False def is_monitoring(self, target): """ Return whether or not we are monitoring the target's online status. """ return target in self._monitoring - ## Callbacks. async def on_user_online(self, nickname): """ Callback called when a monitored user appears online. """ - pass + ... async def on_user_offline(self, nickname): """ Callback called when a monitored users goes offline. """ - pass - + ... ## Message handlers. @@ -77,23 +72,33 @@ async def on_capability_monitor_notify_available(self, value): async def on_raw_730(self, message): """ Someone we are monitoring just came online. """ - for nick in message.params[1].split(','): - self._create_user(nick) + for target in message.params[1].split(','): + nickname, metadata = self._parse_user(target) + await self._sync_user(nickname, metadata) await self.on_user_online(nickname) async def on_raw_731(self, message): """ Someone we are monitoring got offline. """ - for nick in message.params[1].split(','): - self._destroy_user(nick, monitor_override=True) + for target in message.params[1].split(','): + nickname, metadata = self._parse_user(target) + # May be monitoring a user we haven't seen yet + if nickname in self.users: + self._destroy_user(nickname, monitor_override=True) await self.on_user_offline(nickname) async def on_raw_732(self, message): """ List of users we're monitoring. """ - self._monitoring.update(message.params[1].split(',')) + for target in message.params[1].split(','): + nickname, metadata = self._parse_user(target) + self._monitoring.add(nickname) - on_raw_733 = cap.CapabilityNegotiationSupport._ignored # End of MONITOR list. + on_raw_733 = isupport.ISUPPORTSupport._ignored # End of MONITOR list. async def on_raw_734(self, message): """ Monitor list is full, can't add target. """ # Remove from monitoring list, not much else we can do. - self._monitoring.difference_update(message.params[1].split(',')) + to_remove = set() + for target in message.params[1].split(','): + nickname, metadata = self._parse_user(target) + to_remove.add(nickname) + self._monitoring.difference_update(to_remove) diff --git a/pydle/features/ircv3/sasl.py b/pydle/features/ircv3/sasl.py index 6d830f6..9afb292 100644 --- a/pydle/features/ircv3/sasl.py +++ b/pydle/features/ircv3/sasl.py @@ -11,7 +11,7 @@ from . import cap -__all__ = [ 'SASLSupport' ] +__all__ = ['SASLSupport'] RESPONSE_LIMIT = 400 @@ -39,7 +39,6 @@ def _reset_attributes(self): self._sasl_challenge = b'' self._sasl_mechanisms = None - ## SASL functionality. async def _sasl_start(self, mechanism): @@ -102,7 +101,6 @@ async def _sasl_respond(self): if to_send == 0: await self.rawmsg('AUTHENTICATE', EMPTY_MESSAGE) - ## Capability callbacks. async def on_capability_sasl_available(self, value): @@ -149,7 +147,6 @@ async def on_capability_sasl_enabled(self): # Tell caller we need more time, and to not end capability negotiation just yet. return cap.NEGOTIATING - ## Message handlers. async def on_raw_authenticate(self, message): @@ -171,8 +168,7 @@ async def on_raw_authenticate(self, message): # Response not done yet. Restart timer. self._sasl_timer = self.eventloop.call_later(self.SASL_TIMEOUT, self._sasl_abort(timeout=True)) - - on_raw_900 = cap.CapabilityNegotiationSupport._ignored # You are now logged in as... + on_raw_900 = cap.CapabilityNegotiationSupport._ignored # You are now logged in as... async def on_raw_903(self, message): """ SASL authentication successful. """ @@ -186,5 +182,5 @@ async def on_raw_905(self, message): """ Authentication failed. Abort SASL. """ await self._sasl_abort() - on_raw_906 = cap.CapabilityNegotiationSupport._ignored # Completed registration while authenticating/registration aborted. - on_raw_907 = cap.CapabilityNegotiationSupport._ignored # Already authenticated over SASL. + on_raw_906 = cap.CapabilityNegotiationSupport._ignored # Completed registration while authenticating/registration aborted. + on_raw_907 = cap.CapabilityNegotiationSupport._ignored # Already authenticated over SASL. diff --git a/pydle/features/ircv3/tags.py b/pydle/features/ircv3/tags.py index 934ca5e..fb56e94 100644 --- a/pydle/features/ircv3/tags.py +++ b/pydle/features/ircv3/tags.py @@ -1,9 +1,9 @@ ## tags.py # Tagged message support. +import re import pydle.client import pydle.protocol from pydle.features import rfc1459 -import re TAG_INDICATOR = '@' TAG_SEPARATOR = ';' @@ -58,23 +58,28 @@ def parse(cls, line, encoding=pydle.protocol.DEFAULT_ENCODING): raw_tags, message = message.split(' ', 1) for raw_tag in raw_tags.split(TAG_SEPARATOR): + value = None if TAG_VALUE_SEPARATOR in raw_tag: tag, value = raw_tag.split(TAG_VALUE_SEPARATOR, 1) else: + # Valueless or "missing" tag value tag = raw_tag + if not value: + # The tag value was either empty or missing. Per spec, they + # must be treated the same. value = True - # Parse escape sequences since IRC escapes != python escapes - # convert known escapes first - for escape, replacement in TAG_CONVERSIONS.items(): - value = value.replace(escape, replacement) - - # convert other escape sequences based on the spec - pattern =re.compile(r"(\\[\s\S])+") - for match in pattern.finditer(value): - escape = match.group() - value = value.replace(escape, escape[1]) + # Parse escape sequences since IRC escapes != python escapes + if isinstance(value, str): + # convert known escapes first + for escape, replacement in TAG_CONVERSIONS.items(): + value = value.replace(escape, replacement) + # convert other escape sequences based on the spec + pattern = re.compile(r"(\\[\s\S])+") + for match in pattern.finditer(value): + escape = match.group() + value = value.replace(escape, escape[1]) # Finally: add constructed tag to the output object. tags[tag] = value @@ -93,7 +98,7 @@ def construct(self, force=False): if self.tags: raw_tags = [] for tag, value in self.tags.items(): - if value == True: + if value is True: raw_tags.append(tag) else: raw_tags.append(tag + TAG_VALUE_SEPARATOR + value) @@ -109,9 +114,9 @@ def construct(self, force=False): class TaggedMessageSupport(rfc1459.RFC1459Support): - def _create_message(self, command, *params, tags={}, **kwargs): + def _create_message(self, command, *params, tags=None, **kwargs): message = super()._create_message(command, *params, **kwargs) - return TaggedMessage(tags=tags, **message._kw) + return TaggedMessage(tags=tags or {}, **message._kw) def _parse_message(self): sep = rfc1459.protocol.MINIMAL_LINE_SEPARATOR.encode(self.encoding) diff --git a/pydle/features/isupport.py b/pydle/features/isupport.py index 0611cf3..174b79e 100644 --- a/pydle/features/isupport.py +++ b/pydle/features/isupport.py @@ -5,8 +5,7 @@ import pydle.protocol from pydle.features import rfc1459 -__all__ = [ 'ISUPPORTSupport' ] - +__all__ = ['ISUPPORTSupport'] FEATURE_DISABLED_PREFIX = '-' BAN_EXCEPT_MODE = 'e' @@ -32,7 +31,6 @@ def _create_channel(self, channel): if 'INVEX' in self._isupport: self.channels[channel]['inviteexceptlist'] = None - ## Command handlers. async def on_raw_005(self, message): @@ -55,16 +53,15 @@ async def on_raw_005(self, message): # And have callbacks update other internals. for entry, value in isupport.items(): - if value != False: + if value is not False: # A value of True technically means there was no value supplied; correct this for callbacks. - if value == True: + if value is True: value = None method = 'on_isupport_' + pydle.protocol.identifierify(entry) if hasattr(self, method): await getattr(self, method)(value) - ## ISUPPORT handlers. async def on_isupport_awaylen(self, value): @@ -96,23 +93,23 @@ async def on_isupport_chanlimit(self, value): async def on_isupport_chanmodes(self, value): """ Valid channel modes and their behaviour. """ - list, param, param_set, noparams = [ set(modes) for modes in value.split(',')[:4] ] + list, param, param_set, noparams = [set(modes) for modes in value.split(',')[:4]] self._channel_modes.update(set(value.replace(',', ''))) # The reason we have to do it like this is because other ISUPPORTs (e.g. PREFIX) may update these values as well. - if not rfc1459.protocol.BEHAVIOUR_LIST in self._channel_modes_behaviour: + if rfc1459.protocol.BEHAVIOUR_LIST not in self._channel_modes_behaviour: self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_LIST] = set() self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_LIST].update(list) - if not rfc1459.protocol.BEHAVIOUR_PARAMETER in self._channel_modes_behaviour: + if rfc1459.protocol.BEHAVIOUR_PARAMETER not in self._channel_modes_behaviour: self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER] = set() self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER].update(param) - if not rfc1459.protocol.BEHAVIOUR_PARAMETER_ON_SET in self._channel_modes_behaviour: + if rfc1459.protocol.BEHAVIOUR_PARAMETER_ON_SET not in self._channel_modes_behaviour: self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER_ON_SET] = set() self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER_ON_SET].update(param_set) - if not rfc1459.protocol.BEHAVIOUR_NO_PARAMETER in self._channel_modes_behaviour: + if rfc1459.protocol.BEHAVIOUR_NO_PARAMETER not in self._channel_modes_behaviour: self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_NO_PARAMETER] = set() self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_NO_PARAMETER].update(noparams) @@ -179,6 +176,9 @@ async def on_isupport_modes(self, value): """ Maximum number of variable modes to change in a single MODE command. """ self._mode_limit = int(value) + async def on_isupport_monitor(self, value): + self._monitor_limit = int(value) + async def on_isupport_namesx(self, value): """ Let the server know we do in fact support NAMESX. Effectively the same as CAP multi-prefix. """ await self.rawmsg('PROTOCTL', 'NAMESX') @@ -202,7 +202,7 @@ async def on_isupport_prefix(self, value): # Update valid channel modes and their behaviour as CHANMODES doesn't include PREFIX modes. self._channel_modes.update(set(modes)) - if not rfc1459.protocol.BEHAVIOUR_PARAMETER in self._channel_modes_behaviour: + if rfc1459.protocol.BEHAVIOUR_PARAMETER not in self._channel_modes_behaviour: self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER] = set() self._channel_modes_behaviour[rfc1459.protocol.BEHAVIOUR_PARAMETER].update(set(modes)) diff --git a/pydle/features/rfc1459/client.py b/pydle/features/rfc1459/client.py index 2f9faf0..f8a6ba1 100644 --- a/pydle/features/rfc1459/client.py +++ b/pydle/features/rfc1459/client.py @@ -84,8 +84,8 @@ def _create_user(self, nickname): 'away_message': None, }) - def _rename_user(self, user, new): - super()._rename_user(user, new) + async def _rename_user(self, user, new): + await super()._rename_user(user, new) # Rename in mode lists, too. for ch in self.channels.values(): @@ -110,8 +110,7 @@ def _parse_user(self, data): if data: nickname, username, host = parsing.parse_user(data) - metadata = {} - metadata['nickname'] = nickname + metadata = {'nickname': nickname} if username: metadata['username'] = username if host: @@ -178,8 +177,7 @@ def _format_host_range(self, host, range, allow_everything=False): # Wat. if allow_everything and range >= 4: return '*' - else: - return host + return host ## Connection. @@ -376,7 +374,7 @@ async def set_topic(self, channel, topic): """ if not self.is_channel(channel): raise ValueError('Not a channel: {}'.format(channel)) - elif not self.in_channel(channel): + if not self.in_channel(channel): raise NotInChannel(channel) await self.rawmsg('TOPIC', channel, topic) @@ -468,71 +466,71 @@ async def on_connect(self): async def on_invite(self, channel, by): """ Callback called when the client was invited into a channel by someone. """ - pass + ... async def on_user_invite(self, target, channel, by): """ Callback called when another user was invited into a channel by someone. """ - pass + ... async def on_join(self, channel, user): """ Callback called when a user, possibly the client, has joined the channel. """ - pass + ... async def on_kill(self, target, by, reason): """ Callback called when a user, possibly the client, was killed from the server. """ - pass + ... async def on_kick(self, channel, target, by, reason=None): """ Callback called when a user, possibly the client, was kicked from a channel. """ - pass + ... async def on_mode_change(self, channel, modes, by): """ Callback called when the mode on a channel was changed. """ - pass + ... async def on_user_mode_change(self, modes): """ Callback called when a user mode change occurred for the client. """ - pass + ... async def on_message(self, target, by, message): """ Callback called when the client received a message. """ - pass + ... async def on_channel_message(self, target, by, message): """ Callback received when the client received a message in a channel. """ - pass + ... async def on_private_message(self, target, by, message): """ Callback called when the client received a message in private. """ - pass + ... async def on_nick_change(self, old, new): """ Callback called when a user, possibly the client, changed their nickname. """ - pass + ... async def on_notice(self, target, by, message): """ Callback called when the client received a notice. """ - pass + ... async def on_channel_notice(self, target, by, message): """ Callback called when the client received a notice in a channel. """ - pass + ... async def on_private_notice(self, target, by, message): """ Callback called when the client received a notice in private. """ - pass + ... async def on_part(self, channel, user, message=None): """ Callback called when a user, possibly the client, left a channel. """ - pass + ... async def on_topic_change(self, channel, message, by): """ Callback called when the topic for a channel was changed. """ - pass + ... async def on_quit(self, user, message=None): """ Callback called when a user, possibly the client, left the network. """ - pass + ... ## Callback handlers. @@ -547,7 +545,7 @@ async def on_raw_pong(self, message): async def on_raw_invite(self, message): """ INVITE command. """ nick, metadata = self._parse_user(message.source) - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) target, channel = message.params target, metadata = self._parse_user(target) @@ -560,7 +558,7 @@ async def on_raw_invite(self, message): async def on_raw_join(self, message): """ JOIN command. """ nick, metadata = self._parse_user(message.source) - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) channels = message.params[0].split(',') if self.is_same_nick(self.nickname, nick): @@ -583,7 +581,7 @@ async def on_raw_join(self, message): async def on_raw_kick(self, message): """ KICK command. """ kicker, kickermeta = self._parse_user(message.source) - self._sync_user(kicker, kickermeta) + await self._sync_user(kicker, kickermeta) if len(message.params) > 2: channels, targets, reason = message.params @@ -596,7 +594,7 @@ async def on_raw_kick(self, message): for channel, target in itertools.product(channels, targets): target, targetmeta = self._parse_user(target) - self._sync_user(target, targetmeta) + await self._sync_user(target, targetmeta) if self.is_same_nick(target, self.nickname): self._destroy_channel(channel) @@ -613,9 +611,9 @@ async def on_raw_kill(self, message): target, targetmeta = self._parse_user(message.params[0]) reason = message.params[1] - self._sync_user(target, targetmeta) + await self._sync_user(target, targetmeta) if by in self.users: - self._sync_user(by, bymeta) + await self._sync_user(by, bymeta) await self.on_kill(target, by, reason) if self.is_same_nick(self.nickname, target): @@ -628,7 +626,7 @@ async def on_raw_mode(self, message): nick, metadata = self._parse_user(message.source) target, modes = message.params[0], message.params[1:] - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) if self.is_channel(target): if self.in_channel(target): # Parse modes. @@ -637,7 +635,7 @@ async def on_raw_mode(self, message): await self.on_mode_change(target, modes, nick) else: target, targetmeta = self._parse_user(target) - self._sync_user(target, targetmeta) + await self._sync_user(target, targetmeta) # Update own modes. if self.is_same_nick(self.nickname, nick): @@ -650,14 +648,14 @@ async def on_raw_nick(self, message): nick, metadata = self._parse_user(message.source) new = message.params[0] - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) # Acknowledgement of nickname change: set it internally, too. # Alternatively, we were force nick-changed. Nothing much we can do about it. if self.is_same_nick(self.nickname, nick): self.nickname = new # Go through all user lists and replace. - self._rename_user(nick, new) + await self._rename_user(nick, new) # Call handler. await self.on_nick_change(nick, new) @@ -667,7 +665,7 @@ async def on_raw_notice(self, message): nick, metadata = self._parse_user(message.source) target, message = message.params - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) await self.on_notice(target, nick, message) if self.is_channel(target): @@ -684,7 +682,7 @@ async def on_raw_part(self, message): else: reason = None - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) if self.is_same_nick(self.nickname, nick): # We left the channel. Remove from channel list. :( for channel in channels: @@ -707,7 +705,7 @@ async def on_raw_privmsg(self, message): nick, metadata = self._parse_user(message.source) target, message = message.params - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) await self.on_message(target, nick, message) if self.is_channel(target): @@ -719,7 +717,7 @@ async def on_raw_quit(self, message): """ QUIT command. """ nick, metadata = self._parse_user(message.source) - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) if message.params: reason = message.params[0] else: @@ -731,14 +729,14 @@ async def on_raw_quit(self, message): self._destroy_user(nick) # Else, we quit. elif self.connected: - await self.disconnect(expected=True) + await self.disconnect() async def on_raw_topic(self, message): """ TOPIC command. """ setter, settermeta = self._parse_user(message.source) target, topic = message.params - self._sync_user(setter, settermeta) + await self._sync_user(setter, settermeta) # Update topic in our own channel list. if self.in_channel(target): @@ -785,7 +783,7 @@ async def on_raw_301(self, message): } if nickname in self.users: - self._sync_user(nickname, info) + await self._sync_user(nickname, info) if nickname in self._pending['whois']: self._whois_info[nickname].update(info) @@ -798,7 +796,7 @@ async def on_raw_311(self, message): 'realname': realname } - self._sync_user(nickname, info) + await self._sync_user(nickname, info) if nickname in self._pending['whois']: self._whois_info[nickname].update(info) @@ -926,7 +924,7 @@ async def on_raw_353(self, message): if not nick: # nonsense nickname continue - self._sync_user(nick, metadata) + await self._sync_user(nick, metadata) # Get prefixes. prefixes = set(entry.replace(safe_entry, '')) diff --git a/pydle/features/rfc1459/parsing.py b/pydle/features/rfc1459/parsing.py index b0243c1..88f5890 100644 --- a/pydle/features/rfc1459/parsing.py +++ b/pydle/features/rfc1459/parsing.py @@ -4,6 +4,7 @@ import pydle.protocol from . import protocol + class RFC1459Message(pydle.protocol.Message): def __init__(self, command, params, source=None, _raw=None, _valid=True, **kw): self._kw = kw @@ -48,7 +49,7 @@ def parse(cls, line, encoding=pydle.protocol.DEFAULT_ENCODING): if message.startswith(':'): parts = protocol.ARGUMENT_SEPARATOR.split(message[1:], 2) else: - parts = [ None ] + protocol.ARGUMENT_SEPARATOR.split(message, 1) + parts = [None] + protocol.ARGUMENT_SEPARATOR.split(message, 1) if len(parts) == 3: source, command, raw_params = parts @@ -67,12 +68,12 @@ def parse(cls, line, encoding=pydle.protocol.DEFAULT_ENCODING): # Only parameter is a 'trailing' sentence. if raw_params.startswith(protocol.TRAILING_PREFIX): - params = [ raw_params[len(protocol.TRAILING_PREFIX):] ] + params = [raw_params[len(protocol.TRAILING_PREFIX):]] # We have a sentence in our parameters. elif ' ' + protocol.TRAILING_PREFIX in raw_params: index = raw_params.find(' ' + protocol.TRAILING_PREFIX) - # Get all single-word parameters. + # Get all single-word parameters. params = protocol.ARGUMENT_SEPARATOR.split(raw_params[:index].rstrip(' ')) # Extract last parameter as sentence params.append(raw_params[index + len(protocol.TRAILING_PREFIX) + 1:]) @@ -145,6 +146,7 @@ def normalize(input, case_mapping=protocol.DEFAULT_CASE_MAPPING): return input + class NormalizingDict(collections.abc.MutableMapping): """ A dict that normalizes entries according to the given case mapping. """ def __init__(self, *args, case_mapping): @@ -196,6 +198,7 @@ def parse_user(raw): return nick, user, host + def parse_modes(modes, current, behaviour): """ Parse mode change string(s) and return updated dictionary. """ current = current.copy() diff --git a/pydle/features/rfc1459/protocol.py b/pydle/features/rfc1459/protocol.py index af8649e..7b7b02c 100644 --- a/pydle/features/rfc1459/protocol.py +++ b/pydle/features/rfc1459/protocol.py @@ -12,7 +12,6 @@ class ServerError(Error): # While this *technically* is supposed to be 143, I've yet to see a server that actually uses those. DEFAULT_PORT = 6667 - ## Limits. CHANNEL_LIMITS_GROUPS = { @@ -34,7 +33,6 @@ class ServerError(Error): NICKNAME_LENGTH_LIMIT = 8 TOPIC_LENGTH_LIMIT = 450 - ## Defaults. BEHAVIOUR_NO_PARAMETER = 'noparam' @@ -42,33 +40,32 @@ class ServerError(Error): BEHAVIOUR_PARAMETER_ON_SET = 'param_set' BEHAVIOUR_LIST = 'list' -CHANNEL_MODES = { 'o', 'p', 's', 'i', 't', 'n', 'b', 'v', 'm', 'r', 'k', 'l' } +CHANNEL_MODES = {'o', 'p', 's', 'i', 't', 'n', 'b', 'v', 'm', 'r', 'k', 'l'} CHANNEL_MODES_BEHAVIOUR = { - BEHAVIOUR_LIST: { 'b' }, - BEHAVIOUR_PARAMETER: { 'o', 'v' }, - BEHAVIOUR_PARAMETER_ON_SET: { 'k', 'l' }, - BEHAVIOUR_NO_PARAMETER: { 'p', 's', 'i', 't', 'n', 'm', 'r' } + BEHAVIOUR_LIST: {'b'}, + BEHAVIOUR_PARAMETER: {'o', 'v'}, + BEHAVIOUR_PARAMETER_ON_SET: {'k', 'l'}, + BEHAVIOUR_NO_PARAMETER: {'p', 's', 'i', 't', 'n', 'm', 'r'} } -CHANNEL_PREFIXES = { '#', '&' } -CASE_MAPPINGS = { 'ascii', 'rfc1459', 'strict-rfc1459' } +CHANNEL_PREFIXES = {'#', '&'} +CASE_MAPPINGS = {'ascii', 'rfc1459', 'strict-rfc1459'} DEFAULT_CASE_MAPPING = 'rfc1459' NICKNAME_PREFIXES = collections.OrderedDict([ ('@', 'o'), ('+', 'v') ]) -USER_MODES = { 'i', 'w', 's', 'o' } +USER_MODES = {'i', 'w', 's', 'o'} # Maybe one day, user modes will have parameters... USER_MODES_BEHAVIOUR = { - BEHAVIOUR_NO_PARAMETER: { 'i', 'w', 's', 'o' } + BEHAVIOUR_NO_PARAMETER: {'i', 'w', 's', 'o'} } - ## Message parsing. LINE_SEPARATOR = '\r\n' MINIMAL_LINE_SEPARATOR = '\n' -FORBIDDEN_CHARACTERS = { '\r', '\n', '\0' } +FORBIDDEN_CHARACTERS = {'\r', '\n', '\0'} USER_SEPARATOR = '!' HOST_SEPARATOR = '@' diff --git a/pydle/features/rpl_whoishost/rpl_whoishost.py b/pydle/features/rpl_whoishost/rpl_whoishost.py index aebc0df..6106ff6 100644 --- a/pydle/features/rpl_whoishost/rpl_whoishost.py +++ b/pydle/features/rpl_whoishost/rpl_whoishost.py @@ -13,13 +13,15 @@ async def on_raw_378(self, message): host = data[-2] meta = {"real_ip_address": ip_addr, "real_hostname": host} - self._sync_user(target, meta) + await self._sync_user(target, meta) if target in self._whois_info: self._whois_info[target]["real_ip_address"] = ip_addr self._whois_info[target]["real_hostname"] = host async def whois(self, nickname): info = await super().whois(nickname) + if info is None: + return info info.setdefault("real_ip_address", None) info.setdefault("real_hostname", None) return info diff --git a/pydle/features/tls.py b/pydle/features/tls.py index c8fefbe..dabed62 100644 --- a/pydle/features/tls.py +++ b/pydle/features/tls.py @@ -34,13 +34,13 @@ async def connect(self, hostname=None, port=None, tls=False, **kwargs): port = rfc1459.protocol.DEFAULT_PORT return await super().connect(hostname, port, tls=tls, **kwargs) - async def _connect(self, hostname, port, reconnect=False, password=None, encoding=pydle.protocol.DEFAULT_ENCODING, channels=[], tls=False, tls_verify=False, source_address=None): + async def _connect(self, hostname, port, reconnect=False, password=None, encoding=pydle.protocol.DEFAULT_ENCODING, channels=None, tls=False, tls_verify=False, source_address=None): """ Connect to IRC server, optionally over TLS. """ self.password = password # Create connection if we can't reuse it. if not reconnect: - self._autojoin_channels = channels + self._autojoin_channels = channels or [] self.connection = connection.Connection(hostname, port, source_address=source_address, tls=tls, tls_verify=tls_verify, @@ -53,15 +53,15 @@ async def _connect(self, hostname, port, reconnect=False, password=None, encodin # Connect. await self.connection.connect() - ## API. async def whois(self, nickname): info = await super().whois(nickname) + if info is None: + return info info.setdefault('secure', False) return info - ## Message callbacks. async def on_raw_671(self, message): diff --git a/pydle/features/whox.py b/pydle/features/whox.py index 8a6ad35..81d9dbc 100644 --- a/pydle/features/whox.py +++ b/pydle/features/whox.py @@ -6,6 +6,7 @@ # Maximum of 3 characters because Charybdis stupidity. The ASCII values of 'pydle' added together. WHOX_IDENTIFIER = '542' + class WHOXSupport(isupport.ISUPPORTSupport, account.AccountSupport): ## Overrides. @@ -24,11 +25,11 @@ async def on_raw_join(self, message): else: # Find account name of person. pass - - def _create_user(self, nickname): + + async def _create_user(self, nickname): super()._create_user(nickname) if self.registered and 'WHOX' not in self._isupport: - self.whois(nickname) + await self.whois(nickname) async def on_raw_354(self, message): """ WHOX results have arrived. """ @@ -48,4 +49,4 @@ async def on_raw_354(self, message): metadata['identified'] = True metadata['account'] = message.params[5] - self._sync_user(metadata['nickname'], metadata) + await self._sync_user(metadata['nickname'], metadata) diff --git a/pydle/protocol.py b/pydle/protocol.py index 3135d4f..b28e1bd 100644 --- a/pydle/protocol.py +++ b/pydle/protocol.py @@ -26,7 +26,6 @@ def parse(cls, line, encoding=DEFAULT_ENCODING): """ Parse data into IRC message. Return a Message instance or raise an error. """ raise NotImplementedError() - @abstractmethod def construct(self, force=False): """ Convert message into raw IRC command. If `force` is True, don't attempt to check message validity. """ @@ -37,6 +36,7 @@ def __str__(self): ## Misc. + def identifierify(name): """ Clean up name so it works for a Python identifier. """ name = name.lower() diff --git a/pydle/utils/_args.py b/pydle/utils/_args.py index 0b51242..fe71bc7 100644 --- a/pydle/utils/_args.py +++ b/pydle/utils/_args.py @@ -5,6 +5,7 @@ import logging import pydle + def client_from_args(name, description, default_nick='Bot', cls=pydle.Client): # Parse some arguments. parser = argparse.ArgumentParser(name, description=description, add_help=False, diff --git a/pydle/utils/irccat.py b/pydle/utils/irccat.py index 47a857c..9226197 100644 --- a/pydle/utils/irccat.py +++ b/pydle/utils/irccat.py @@ -2,63 +2,61 @@ ## irccat.py # Simple threaded irccat implementation, using pydle. import sys -import os -import threading import logging import asyncio -from asyncio.streams import FlowControlMixin -from .. import Client, __version__ +from .. import Client, __version__ from . import _args -import asyncio + class IRCCat(Client): """ irccat. Takes raw messages on stdin, dumps raw messages to stdout. Life has never been easier. """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.async_stdin = None - @asyncio.coroutine - def _send(self, data): - sys.stdout.write(data) - yield from super()._send(data) + async def _send(self, data): + await super()._send(data) - @asyncio.coroutine - def process_stdin(self): + async def process_stdin(self): """ Yes. """ - loop = self.eventloop.loop + loop = asyncio.get_event_loop() self.async_stdin = asyncio.StreamReader() reader_protocol = asyncio.StreamReaderProtocol(self.async_stdin) - yield from loop.connect_read_pipe(lambda: reader_protocol, sys.stdin) + await loop.connect_read_pipe(lambda: reader_protocol, sys.stdin) while True: - line = yield from self.async_stdin.readline() + line = await self.async_stdin.readline() if not line: break - yield from self.raw(line.decode('utf-8')) + await self.raw(line.decode('utf-8')) - yield from self.quit('EOF') + await self.quit('EOF') - @asyncio.coroutine - def on_raw(self, message): + async def on_raw(self, message): print(message._raw) - yield from super().on_raw(message) + await super().on_raw(message) - @asyncio.coroutine - def on_ctcp_version(self, source, target, contents): - self.ctcp_reply(source, 'VERSION', 'pydle-irccat v{}'.format(__version__)) + async def on_ctcp_version(self, source, target, contents): + await self.ctcp_reply(source, 'VERSION', 'pydle-irccat v{}'.format(__version__)) + + +async def _main(): + # Create client. + irccat, connect = _args.client_from_args('irccat', default_nick='irccat', + description='Process raw IRC messages from stdin, dump received IRC messages to stdout.', + cls=IRCCat) + await connect() + while True: + await irccat.process_stdin() def main(): # Setup logging. logging.basicConfig(format='!! %(levelname)s: %(message)s') - - # Create client. - irccat, connect = _args.client_from_args('irccat', default_nick='irccat', description='Process raw IRC messages from stdin, dump received IRC messages to stdout.', cls=IRCCat) - - irccat.eventloop.schedule_async(connect()) - irccat.eventloop.run_with(irccat.process_stdin()) + asyncio.get_event_loop().run_until_complete(_main()) if __name__ == '__main__': diff --git a/pydle/utils/run.py b/pydle/utils/run.py index c572e56..3af6742 100644 --- a/pydle/utils/run.py +++ b/pydle/utils/run.py @@ -1,14 +1,15 @@ ## run.py # Run client. import asyncio -import pydle from . import _args + def main(): client, connect = _args.client_from_args('pydle', description='pydle IRC library.') loop = asyncio.get_event_loop() asyncio.ensure_future(connect(), loop=loop) loop.run_forever() + if __name__ == '__main__': main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..207797e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[tool.poetry] +name = "pydle" +version = "1.0.0" +description = "A compact, flexible, and standards-abiding IRC library for python3." +authors = ["Shiz "] +repository = "https://github.com/Shizmob/pydle" +keywords = ["irc", "library","python3","compact","flexible"] +license = "BSD" + +[tool.poetry.dependencies] +python = ">=3.6;<3.10" + +[tool.poetry.dependencies.pure-sasl] +version = "^0.6.2" +optional = true + +# Stuff needed for development, but not for install&usage +[tool.poetry.dev-dependencies] +sphinx-rtd-theme = "^1.0.0" +Sphinx = "^5.0.2" + + +[tool.poetry.extras] +sasl = ["pure-sasl"] + +[tool.poetry.scripts] +pydle = "pydle.utils.run:main" +pydle-irccat = 'pydle.utils.irccat:main' + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3eda13c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +asyncio_mode = auto +markers = + meta: mark a test as meta + slow: mark a test as sssslllooowwww + ircv3: mark a test as related to v3 of the IRC standard + unit: mark a test as relating to the unit \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 8e574a3..0000000 --- a/setup.py +++ /dev/null @@ -1,37 +0,0 @@ -from setuptools import setup - -setup( - name='pydle', - version='0.9.4', - python_requires=">=3.5", - packages=[ - 'pydle', - 'pydle.features', - 'pydle.features.rpl_whoishost', - 'pydle.features.rfc1459', - 'pydle.features.ircv3', - 'pydle.utils' - ], - extras_require={ - 'sasl': 'pure-sasl >=0.1.6', # for pydle.features.sasl - 'docs': 'sphinx_rtd_theme', # the Sphinx theme we use - 'tests': 'pytest', # collect and run tests - 'coverage': 'pytest-cov' # get test case coverage - }, - entry_points={ - 'console_scripts': [ - 'pydle = pydle.utils.run:main', - 'pydle-irccat = pydle.utils.irccat:main' - ] - }, - - author='Shiz', - author_email='hi@shiz.me', - url='https://github.com/Shizmob/pydle', - keywords='irc library python3 compact flexible', - description='A compact, flexible and standards-abiding IRC library for Python 3.', - license='BSD', - - zip_safe=True, - test_suite='tests' -) diff --git a/tests/conftest.py b/tests/conftest.py index d622446..b275bce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,22 +4,25 @@ def pytest_addoption(parser): # Add option to skip meta (test suite-testing) tests. - parser.addoption('--skip-meta', action='store_true', help='skip test suite-testing tests') + parser.addoption( + "--skip-meta", action="store_true", help="skip test suite-testing tests" + ) # Add option to skip slow tests. - parser.addoption('--skip-slow', action='store_true', help='skip slow tests') + parser.addoption("--skip-slow", action="store_true", help="skip slow tests") # Add option to skip real life tests. - parser.addoption('--skip-real', action='store_true', help='skip real life tests') + parser.addoption("--skip-real", action="store_true", help="skip real life tests") def pytest_runtest_setup(item): - if 'meta' in item.keywords and item.config.getoption('--skip-meta'): - pytest.skip('skipping meta test (--skip-meta given)') - if 'slow' in item.keywords and item.config.getoption('--skip-slow'): - pytest.skip('skipping slow test (--skip-slow given)') + if "meta" in item.keywords and item.config.getoption("--skip-meta"): + pytest.skip("skipping meta test (--skip-meta given)") + if "slow" in item.keywords and item.config.getoption("--skip-slow"): + pytest.skip("skipping slow test (--skip-slow given)") - if 'real' in item.keywords: - if item.config.getoption('--skip-real'): - pytest.skip('skipping real life test (--skip-real given)') - if (not os.getenv('PYDLE_TESTS_REAL_HOST') or - not os.getenv('PYDLE_TESTS_REAL_PORT')): - pytest.skip('skipping real life test (no real server given)') + if "real" in item.keywords: + if item.config.getoption("--skip-real"): + pytest.skip("skipping real life test (--skip-real given)") + if not os.getenv("PYDLE_TESTS_REAL_HOST") or not os.getenv( + "PYDLE_TESTS_REAL_PORT" + ): + pytest.skip("skipping real life test (no real server given)") diff --git a/tests/fixtures.py b/tests/fixtures.py index 1a904dc..bd8a28b 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,5 +1,5 @@ import pydle -from .mocks import MockServer, MockClient, MockEventLoop +from .mocks import MockServer, MockClient def with_client(*features, connected=True, **options): @@ -9,21 +9,18 @@ def with_client(*features, connected=True, **options): with_client.classes[features] = pydle.featurize(MockClient, *features) def inner(f): - def run(): + async def run(): server = MockServer() - client = with_client.classes[features]('TestcaseRunner', mock_server=server, **options) + client = with_client.classes[features]( + "TestcaseRunner", mock_server=server, **options + ) if connected: - client.connect('mock://local', 1337, eventloop=MockEventLoop()) - - try: - ret = f(client=client, server=server) - return ret - finally: - if client.eventloop: - client.eventloop.stop() + await client.connect("mock://local", 1337) run.__name__ = f.__name__ return run + return inner + with_client.classes = {} diff --git a/tests/mocks.py b/tests/mocks.py index f620da3..329e1c2 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -1,5 +1,3 @@ -import threading -import datetime import json import pydle @@ -17,7 +15,7 @@ class MockServer: def __init__(self): self.connection = None - self.recvbuffer = '' + self.recvbuffer = "" self.msgbuffer = [] def receive(self, *args, **kwargs): @@ -34,20 +32,20 @@ def received(self, *args, **kwargs): def receiveddata(self, data): if data in self.recvbuffer: - self.recvbuffer.replace(data, '', 1) + self.recvbuffer.replace(data, "", 1) return True return False - def send(self, *args, **kwargs): + async def send(self, *args, **kwargs): msg = self.connection._mock_client._create_message(*args, **kwargs) - self.connection._mock_client.on_raw(msg) + await self.connection._mock_client.on_raw(msg) def sendraw(self, data): self.connection._mock_client.on_data(data) class MockClient(pydle.client.BasicClient): - """ A client that subtitutes its own connection for a mock connection to MockServer. """ + """A client that subtitutes its own connection for a mock connection to MockServer.""" def __init__(self, *args, mock_server=None, **kwargs): self._mock_server = mock_server @@ -62,31 +60,37 @@ def logger(self): def logger(self, val): pass - def _connect(self, hostname, port, *args, **kwargs): - self.connection = MockConnection(hostname, port, mock_client=self, mock_server=self._mock_server, eventloop=self.eventloop) - self.connection.connect() - self.on_connect() - - def raw(self, data): + async def _connect(self, hostname, port, *args, **kwargs): + self.connection = MockConnection( + hostname, + port, + mock_client=self, + mock_server=self._mock_server, + eventloop=self.eventloop, + ) + await self.connection.connect() + await self.on_connect() + + async def raw(self, data): self.connection._mock_server.receivedata(data) - def rawmsg(self, *args, **kwargs): + async def rawmsg(self, *args, **kwargs): self.connection._mock_server.receive(*args, **kwargs) def _create_message(self, *args, **kwargs): return MockMessage(*args, **kwargs) def _has_message(self): - return b'\r\n' in self._receive_buffer + return b"\r\n" in self._receive_buffer def _parse_message(self): - message, _, data = self._receive_buffer.partition(b'\r\n') + message, _, data = self._receive_buffer.partition(b"\r\n") self._receive_buffer = data - return MockMessage.parse(message + b'\r\n', encoding=self.encoding) + return MockMessage.parse(message + b"\r\n", encoding=self.encoding) class MockConnection(pydle.connection.Connection): - """ A mock connection between a client and a server. """ + """A mock connection between a client and a server.""" def __init__(self, *args, mock_client=None, mock_server=None, **kwargs): super().__init__(*args, **kwargs) @@ -104,94 +108,15 @@ def off(self, *args, **kwargs): def connected(self): return self._mock_connected - def connect(self, *args, **kwargs): + async def connect(self, *args, **kwargs): self._mock_server.connection = self self._mock_connected = True - def disconnect(self, *args, **kwargs): + async def disconnect(self, *args, **kwargs): self._mock_server.connection = None self._mock_connected = False -class MockEventLoop: - """ A mock event loop for use in testing. """ - - def __init__(self, *args, **kwargs): - self._mock_timers = {} - self._mock_periodical_id = 0 - self.running = False - - def __del__(self): - pass - - def run(self): - self.running = True - - def run_with(self, func): - self.running = True - func() - self.stop() - - def run_until(self, future): - self.running = True - future.result() - self.stop() - - def stop(self): - self.running = False - for timer in self._mock_timers.values(): - timer.cancel() - - def schedule(self, f, *args, **kwargs): - f(*args, **kwargs) - - def schedule_in(self, _delay, _f, *_args, **_kw): - if isinstance(_delay, datetime.timedelta): - _delay = _delay.total_seconds() - - timer = threading.Timer(_delay, _f, _args, _kw) - timer.start() - - id = self._mock_periodical_id - self._mock_timers[id] = timer - self._mock_periodical_id += 1 - return id - - def schedule_periodically(self, _delay, _f, *_args, **_kw): - if isinstance(_delay, datetime.timedelta): - _delay = _delay.total_seconds() - - id = self._mock_periodical_id - timer = threading.Timer(_delay, self._do_schedule_periodically, (_f, _delay, id, _args, _kw)) - timer.start() - - self._mock_timers[id] = timer - self._mock_periodical_id += 1 - return id - - def _do_schedule_periodically(self, f, delay, id, args, kw): - if not self.is_scheduled(id): - return - - timer = threading.Timer(delay, self._do_schedule_periodically, (f, delay, id, args, kw)) - timer.start() - self._mock_timers[id] = timer - result = False - - try: - result = f(*args, **kw) - finally: - if result == False: - self.unschedule(id) - - def is_scheduled(self, handle): - return handle in self._mock_timers - - def unschedule(self, handle): - self._mock_timers[handle].cancel() - del self._mock_timers[handle] - - class MockMessage(pydle.protocol.Message): def __init__(self, command, *params, source=None, **kw): self.command = command @@ -213,9 +138,21 @@ def parse(cls, line, encoding=pydle.protocol.DEFAULT_ENCODING): try: val = json.loads(message) except: - raise pydle.protocol.ProtocolViolation('Invalid JSON') + raise pydle.protocol.ProtocolViolation("Invalid JSON") - return MockMessage(val['command'], *val['params'], source=val['source'], **val['kw']) + return MockMessage( + val["command"], *val["params"], source=val["source"], **val["kw"] + ) def construct(self): - return json.dumps({ 'command': self.command, 'params': self.params, 'source': self.source, 'kw': self.kw }) + '\r\n' + return ( + json.dumps( + { + "command": self.command, + "params": self.params, + "source": self.source, + "kw": self.kw, + } + ) + + "\r\n" + ) diff --git a/tests/test__fixtures.py b/tests/test__fixtures.py index ef80eb0..6c38a7f 100644 --- a/tests/test__fixtures.py +++ b/tests/test__fixtures.py @@ -1,36 +1,47 @@ -import pydle - +import pytest from pytest import mark +import pydle from .fixtures import with_client -from .mocks import MockClient, MockServer, MockConnection, MockEventLoop +from .mocks import MockClient, MockServer, MockConnection +@pytest.mark.asyncio @mark.meta @with_client(connected=False) def test_fixtures_with_client(server, client): assert isinstance(server, MockServer) assert isinstance(client, MockClient) - assert client.__class__.__mro__[1] is MockClient, 'MockClient should be first in method resolution order' + assert ( + client.__class__.__mro__[1] is MockClient + ), "MockClient should be first in method resolution order" assert not client.connected + +@pytest.mark.asyncio @mark.meta @with_client(pydle.features.RFC1459Support, connected=False) def test_fixtures_with_client_features(server, client): assert isinstance(client, MockClient) - assert client.__class__.__mro__[1] is MockClient, 'MockClient should be first in method resolution order' + assert ( + client.__class__.__mro__[1] is MockClient + ), "MockClient should be first in method resolution order" assert isinstance(client, pydle.features.RFC1459Support) + +@pytest.mark.asyncio @mark.meta -@with_client(username='test_runner') +@with_client(username="test_runner") def test_fixtures_with_client_options(server, client): - assert client.username == 'test_runner' + assert client.username == "test_runner" + +@pytest.mark.asyncio @mark.meta @with_client() -def test_fixtures_with_client_connected(server, client): +async def test_fixtures_with_client_connected(server, client): assert client.connected - assert isinstance(client.eventloop, MockEventLoop) + assert isinstance(client.eventloop) assert isinstance(client.connection, MockConnection) - assert isinstance(client.connection.eventloop, MockEventLoop) + assert isinstance(client.connection.eventloop) assert client.eventloop is client.connection.eventloop diff --git a/tests/test__mocks.py b/tests/test__mocks.py index cd1b749..90c16a2 100644 --- a/tests/test__mocks.py +++ b/tests/test__mocks.py @@ -1,10 +1,8 @@ -import time -import datetime -import pydle - +import pytest from pytest import mark +import pydle from .fixtures import with_client -from .mocks import Mock, MockEventLoop, MockConnection +from .mocks import Mock, MockConnection class Passed: @@ -23,12 +21,14 @@ def reset(self): ## Client. + +@pytest.mark.asyncio @mark.meta @with_client(connected=False) -def test_mock_client_connect(server, client): +async def test_mock_client_connect(server, client): assert not client.connected client.on_connect = Mock() - client.connect('mock://local', 1337, eventloop=MockEventLoop()) + await client.connect("mock://local", 1337) assert client.connected assert client.on_connect.called @@ -36,135 +36,52 @@ def test_mock_client_connect(server, client): client.disconnect() assert not client.connected + +@pytest.mark.asyncio @mark.meta @with_client() -def test_mock_client_send(server, client): - client.raw('benis') - assert server.receiveddata('benis') - client.rawmsg('INSTALL', 'Gentoo') - assert server.received('INSTALL', 'Gentoo') +async def test_mock_client_send(server, client): + await client.raw("benis") + assert server.receiveddata("benis") + await client.rawmsg("INSTALL", "Gentoo") + assert server.received("INSTALL", "Gentoo") + +@pytest.mark.asyncio @mark.meta @with_client(pydle.features.RFC1459Support) -def test_mock_client_receive(server, client): +async def test_mock_client_receive(server, client): client.on_raw = Mock() - server.send('PING', 'test') + server.send("PING", "test") assert client.on_raw.called message = client.on_raw.call_args[0][0] assert isinstance(message, pydle.protocol.Message) assert message.source is None - assert message.command == 'PING' - assert message.params == ('test',) + assert message.command == "PING" + assert message.params == ("test",) ## Connection. + +@pytest.mark.asyncio @mark.meta -def test_mock_connection_connect(): +async def test_mock_connection_connect(): serv = Mock() - conn = MockConnection('mock.local', port=1337, mock_server=serv) + conn = MockConnection("mock.local", port=1337, mock_server=serv) - conn.connect() + await conn.connect() assert conn.connected assert serv.connection is conn + +@pytest.mark.asyncio @mark.meta -def test_mock_connection_disconnect(): +async def test_mock_connection_disconnect(): serv = Mock() - conn = MockConnection('mock.local', port=1337, mock_server=serv) + conn = MockConnection("mock.local", port=1337, mock_server=serv) - conn.connect() - conn.disconnect() + await conn.connect() + await conn.disconnect() assert not conn.connected - - -## Event loop. - -@mark.meta -def test_mock_eventloop_schedule(): - ev = MockEventLoop() - passed = Passed() - - ev.schedule(lambda: passed.set()) - assert passed - - ev.stop() - -@mark.meta -@mark.slow -def test_mock_eventloop_schedule_in(): - ev = MockEventLoop() - passed = Passed() - - ev.schedule_in(1, lambda: passed.set()) - time.sleep(1.1) - assert passed - - ev.stop() - -@mark.meta -@mark.slow -def test_mock_eventloop_schedule_in_timedelta(): - ev = MockEventLoop() - passed = Passed() - - ev.schedule_in(datetime.timedelta(seconds=1), lambda: passed.set()) - time.sleep(1.1) - assert passed - -@mark.meta -@mark.slow -def test_mock_eventloop_schedule_periodically(): - ev = MockEventLoop() - passed = Passed() - - ev.schedule_periodically(1, lambda: passed.set()) - time.sleep(1.1) - assert passed - - passed.reset() - time.sleep(1) - assert passed - - ev.stop() - -@mark.meta -@mark.slow -def test_mock_eventloop_unschedule_in(): - ev = MockEventLoop() - passed = Passed() - - handle = ev.schedule_in(1, lambda: passed.set()) - ev.unschedule(handle) - - time.sleep(1.1) - assert not passed - -@mark.meta -@mark.slow -def test_mock_eventloop_unschedule_periodically(): - ev = MockEventLoop() - passed = Passed() - - handle = ev.schedule_periodically(1, lambda: passed.set()) - ev.unschedule(handle) - - time.sleep(1.1) - assert not passed - -@mark.meta -@mark.slow -def test_mock_eventloop_unschedule_periodically_after(): - ev = MockEventLoop() - passed = Passed() - - handle = ev.schedule_periodically(1, lambda: passed.set()) - - time.sleep(1.1) - assert passed - - passed.reset() - ev.unschedule(handle) - time.sleep(1.0) - assert not passed diff --git a/tests/test_client.py b/tests/test_client.py index 7ad4b3a..e6f7e31 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,56 +1,66 @@ import time -import pydle - +import pytest from pytest import raises, mark +import pydle from .fixtures import with_client -from .mocks import Mock, MockEventLoop +from .mocks import Mock pydle.client.PING_TIMEOUT = 10 ## Initialization. + +@pytest.mark.asyncio @with_client(invalid_kwarg=False) def test_client_superfluous_arguments(server, client): assert client.logger.warning.called ## Connection. - +@pytest.mark.asyncio @with_client() -def test_client_reconnect(server, client): - client.disconnect(expected=True) +async def test_client_reconnect(server, client): + await client.disconnect(expected=True) assert not client.connected - client.connect(reconnect=True) + await client.connect(reconnect=True) assert client.connected + +@pytest.mark.asyncio @mark.slow @with_client() -def test_client_unexpected_disconnect_reconnect(server, client): +async def test_client_unexpected_disconnect_reconnect(server, client): client._reconnect_delay = Mock(return_value=0) - client.disconnect(expected=False) + await client.disconnect(expected=False) assert client._reconnect_delay.called time.sleep(0.1) assert client.connected + +@pytest.mark.asyncio @with_client() -def test_client_unexpected_reconnect_give_up(server, client): +async def test_client_unexpected_reconnect_give_up(server, client): client.RECONNECT_ON_ERROR = False - client.disconnect(expected=False) + await client.disconnect(expected=False) assert not client.connected + +@pytest.mark.asyncio @mark.slow @with_client() -def test_client_unexpected_disconnect_reconnect_delay(server, client): +async def test_client_unexpected_disconnect_reconnect_delay(server, client): client._reconnect_delay = Mock(return_value=1) - client.disconnect(expected=False) + await client.disconnect(expected=False) assert not client.connected time.sleep(1.1) assert client.connected + +@pytest.mark.asyncio @with_client() def test_client_reconnect_delay_calculation(server, client): client.RECONNECT_DELAYED = False @@ -65,70 +75,81 @@ def test_client_reconnect_delay_calculation(server, client): assert client._reconnect_delay() == client.RECONNECT_DELAYS[-1] + +@pytest.mark.asyncio @with_client() -def test_client_disconnect_on_connect(server, client): +async def test_client_disconnect_on_connect(server, client): client.disconnect = Mock() - client.connect('mock://local', 1337) + await client.connect("mock://local", 1337) assert client.connected assert client.disconnect.called + +@pytest.mark.asyncio @with_client(connected=False) -def test_client_connect_invalid_params(server, client): +async def test_client_connect_invalid_params(server, client): with raises(ValueError): - client.connect() + await client.connect() with raises(ValueError): - client.connect(port=1337) + await client.connect(port=1337) + +@pytest.mark.asyncio @mark.slow @with_client() -def test_client_timeout(server, client): +async def test_client_timeout(server, client): client.on_data_error = Mock() - time.sleep(pydle.client.PING_TIMEOUT + 1) + time.sleep(pydle.client.BasicClient.READ_TIMEOUT + 1) assert client.on_data_error.called assert isinstance(client.on_data_error.call_args[0][0], TimeoutError) + +@pytest.mark.asyncio @with_client(connected=False) -def test_client_server_tag(server, client): - ev = MockEventLoop() +async def test_client_server_tag(server, client): assert client.server_tag is None - client.connect('Mock.local', 1337, eventloop=ev) - assert client.server_tag == 'mock' - client.disconnect() + await client.connect("Mock.local", 1337) + assert client.server_tag == "mock" + await client.disconnect() - client.connect('irc.mock.local', 1337, eventloop=ev) - assert client.server_tag == 'mock' - client.disconnect() + await client.connect("irc.mock.local", 1337) + assert client.server_tag == "mock" + await client.disconnect() - client.connect('mock', 1337, eventloop=ev) - assert client.server_tag == 'mock' - client.disconnect() + await client.connect("mock", 1337) + assert client.server_tag == "mock" + await client.disconnect() - client.connect('127.0.0.1', 1337, eventloop=ev) - assert client.server_tag == '127.0.0.1' + await client.connect("127.0.0.1", 1337) + assert client.server_tag == "127.0.0.1" - client.network = 'MockNet' - assert client.server_tag == 'mocknet' - client.disconnect() + client.network = "MockNet" + assert client.server_tag == "mocknet" + await client.disconnect() ## Messages. + +@pytest.mark.asyncio @with_client() -def test_client_message(server, client): +async def test_client_message(server, client): client.on_raw_install = Mock() - server.send('INSTALL', 'gentoo') + await server.send("INSTALL", "gentoo") assert client.on_raw_install.called message = client.on_raw_install.call_args[0][0] assert isinstance(message, pydle.protocol.Message) - assert message.command == 'INSTALL' - assert message.params == ('gentoo',) + assert message.command == "INSTALL" + assert message.params == ("gentoo",) + +@pytest.mark.asyncio @with_client() -def test_client_unknown(server, client): +async def test_client_unknown(server, client): client.on_unknown = Mock() - server.send('INSTALL', 'gentoo') + await server.send("INSTALL", "gentoo") assert client.on_unknown.called diff --git a/tests/test_client_channels.py b/tests/test_client_channels.py index 5aab225..5a64742 100644 --- a/tests/test_client_channels.py +++ b/tests/test_client_channels.py @@ -1,43 +1,54 @@ -import pydle +import pytest from .fixtures import with_client +@pytest.mark.asyncio @with_client() def test_client_same_channel(server, client): - assert client.is_same_channel('#lobby', '#lobby') - assert not client.is_same_channel('#lobby', '#support') - assert not client.is_same_channel('#lobby', 'jilles') + assert client.is_same_channel("#lobby", "#lobby") + assert not client.is_same_channel("#lobby", "#support") + assert not client.is_same_channel("#lobby", "jilles") + +@pytest.mark.asyncio @with_client() def test_client_in_channel(server, client): - client._create_channel('#lobby') - assert client.in_channel('#lobby') + client._create_channel("#lobby") + assert client.in_channel("#lobby") + +@pytest.mark.asyncio @with_client() def test_client_is_channel(server, client): # Test always true... - assert client.is_channel('#lobby') - assert client.is_channel('WiZ') - assert client.is_channel('irc.fbi.gov') + assert client.is_channel("#lobby") + assert client.is_channel("WiZ") + assert client.is_channel("irc.fbi.gov") + +@pytest.mark.asyncio @with_client() def test_channel_creation(server, client): - client._create_channel('#pydle') - assert '#pydle' in client.channels - assert client.channels['#pydle']['users'] == set() + client._create_channel("#pydle") + assert "#pydle" in client.channels + assert client.channels["#pydle"]["users"] == set() + +@pytest.mark.asyncio @with_client() def test_channel_destruction(server, client): - client._create_channel('#pydle') - client._destroy_channel('#pydle') - assert '#pydle' not in client.channels + client._create_channel("#pydle") + client._destroy_channel("#pydle") + assert "#pydle" not in client.channels + +@pytest.mark.asyncio @with_client() -def test_channel_user_destruction(server, client): - client._create_channel('#pydle') - client._create_user('WiZ') - client.channels['#pydle']['users'].add('WiZ') - - client._destroy_channel('#pydle') - assert '#pydle' not in client.channels - assert 'WiZ' not in client.users +async def test_channel_user_destruction(server, client): + client._create_channel("#pydle") + await client._create_user("WiZ") + client.channels["#pydle"]["users"].add("WiZ") + + client._destroy_channel("#pydle") + assert "#pydle" not in client.channels + assert "WiZ" not in client.users diff --git a/tests/test_client_users.py b/tests/test_client_users.py index b534507..5857f2b 100644 --- a/tests/test_client_users.py +++ b/tests/test_client_users.py @@ -1,118 +1,140 @@ -import pydle +import pytest from .fixtures import with_client +@pytest.mark.asyncio @with_client() def test_client_same_nick(server, client): - assert client.is_same_nick('WiZ', 'WiZ') - assert not client.is_same_nick('WiZ', 'jilles') - assert not client.is_same_nick('WiZ', 'wiz') + assert client.is_same_nick("WiZ", "WiZ") + assert not client.is_same_nick("WiZ", "jilles") + assert not client.is_same_nick("WiZ", "wiz") +@pytest.mark.asyncio @with_client() -def test_user_creation(server, client): - client._create_user('WiZ') - assert 'WiZ' in client.users - assert client.users['WiZ']['nickname'] == 'WiZ' +async def test_user_creation(server, client): + await client._create_user("WiZ") + assert "WiZ" in client.users + assert client.users["WiZ"]["nickname"] == "WiZ" + +@pytest.mark.asyncio @with_client() -def test_user_invalid_creation(server, client): - client._create_user('irc.fbi.gov') - assert 'irc.fbi.gov' not in client.users +async def test_user_invalid_creation(server, client): + await client._create_user("irc.fbi.gov") + assert "irc.fbi.gov" not in client.users +@pytest.mark.asyncio @with_client() -def test_user_renaming(server, client): - client._create_user('WiZ') - client._rename_user('WiZ', 'jilles') +async def test_user_renaming(server, client): + await client._create_user("WiZ") + await client._rename_user("WiZ", "jilles") + + assert "WiZ" not in client.users + assert "jilles" in client.users + assert client.users["jilles"]["nickname"] == "jilles" - assert 'WiZ' not in client.users - assert 'jilles' in client.users - assert client.users['jilles']['nickname'] == 'jilles' +@pytest.mark.asyncio @with_client() -def test_user_renaming_creation(server, client): - client._rename_user('null', 'WiZ') +async def test_user_renaming_creation(server, client): + await client._rename_user("null", "WiZ") - assert 'WiZ' in client.users - assert 'null' not in client.users + assert "WiZ" in client.users + assert "null" not in client.users + +@pytest.mark.asyncio @with_client() -def test_user_renaming_invalid_creation(server, client): - client._rename_user('null', 'irc.fbi.gov') +async def test_user_renaming_invalid_creation(server, client): + await client._rename_user("null", "irc.fbi.gov") + + assert "irc.fbi.gov" not in client.users + assert "null" not in client.users - assert 'irc.fbi.gov' not in client.users - assert 'null' not in client.users +@pytest.mark.asyncio @with_client() -def test_user_renaming_channel_users(server, client): - client._create_user('WiZ') - client._create_channel('#lobby') - client.channels['#lobby']['users'].add('WiZ') +async def test_user_renaming_channel_users(server, client): + await client._create_user("WiZ") + client._create_channel("#lobby") + client.channels["#lobby"]["users"].add("WiZ") - client._rename_user('WiZ', 'jilles') - assert 'WiZ' not in client.channels['#lobby']['users'] - assert 'jilles' in client.channels['#lobby']['users'] + await client._rename_user("WiZ", "jilles") + assert "WiZ" not in client.channels["#lobby"]["users"] + assert "jilles" in client.channels["#lobby"]["users"] +@pytest.mark.asyncio @with_client() -def test_user_deletion(server, client): - client._create_user('WiZ') - client._destroy_user('WiZ') +async def test_user_deletion(server, client): + await client._create_user("WiZ") + client._destroy_user("WiZ") - assert 'WiZ' not in client.users + assert "WiZ" not in client.users + +@pytest.mark.asyncio @with_client() -def test_user_channel_deletion(server, client): - client._create_channel('#lobby') - client._create_user('WiZ') - client.channels['#lobby']['users'].add('WiZ') +async def test_user_channel_deletion(server, client): + client._create_channel("#lobby") + await client._create_user("WiZ") + client.channels["#lobby"]["users"].add("WiZ") + + client._destroy_user("WiZ", "#lobby") + assert "WiZ" not in client.users + assert client.channels["#lobby"]["users"] == set() - client._destroy_user('WiZ', '#lobby') - assert 'WiZ' not in client.users - assert client.channels['#lobby']['users'] == set() +@pytest.mark.asyncio @with_client() -def test_user_channel_incomplete_deletion(server, client): - client._create_channel('#lobby') - client._create_channel('#foo') - client._create_user('WiZ') - client.channels['#lobby']['users'].add('WiZ') - client.channels['#foo']['users'].add('WiZ') +async def test_user_channel_incomplete_deletion(server, client): + client._create_channel("#lobby") + client._create_channel("#foo") + await client._create_user("WiZ") + client.channels["#lobby"]["users"].add("WiZ") + client.channels["#foo"]["users"].add("WiZ") - client._destroy_user('WiZ', '#lobby') - assert 'WiZ' in client.users - assert client.channels['#lobby']['users'] == set() + client._destroy_user("WiZ", "#lobby") + assert "WiZ" in client.users + assert client.channels["#lobby"]["users"] == set() +@pytest.mark.asyncio @with_client() -def test_user_synchronization(server, client): - client._create_user('WiZ') - client._sync_user('WiZ', { 'hostname': 'og.irc.developer' }) +async def test_user_synchronization(server, client): + await client._create_user("WiZ") + await client._sync_user("WiZ", {"hostname": "og.irc.developer"}) - assert client.users['WiZ']['hostname'] == 'og.irc.developer' + assert client.users["WiZ"]["hostname"] == "og.irc.developer" + +@pytest.mark.asyncio @with_client() -def test_user_synchronization_creation(server, client): - client._sync_user('WiZ', {}) - assert 'WiZ' in client.users +async def test_user_synchronization_creation(server, client): + await client._sync_user("WiZ", {}) + assert "WiZ" in client.users + +@pytest.mark.asyncio @with_client() -def test_user_invalid_synchronization(server, client): - client._sync_user('irc.fbi.gov', {}) - assert 'irc.fbi.gov' not in client.users +async def test_user_invalid_synchronization(server, client): + await client._sync_user("irc.fbi.gov", {}) + assert "irc.fbi.gov" not in client.users +@pytest.mark.asyncio @with_client() -def test_user_mask_format(server, client): - client._create_user('WiZ') - assert client._format_user_mask('WiZ') == 'WiZ!*@*' +async def test_user_mask_format(server, client): + await client._create_user("WiZ") + assert client._format_user_mask("WiZ") == "WiZ!*@*" - client._sync_user('WiZ', { 'username': 'wiz' }) - assert client._format_user_mask('WiZ') == 'WiZ!wiz@*' + await client._sync_user("WiZ", {"username": "wiz"}) + assert client._format_user_mask("WiZ") == "WiZ!wiz@*" - client._sync_user('WiZ', { 'hostname': 'og.irc.developer' }) - assert client._format_user_mask('WiZ') == 'WiZ!wiz@og.irc.developer' + await client._sync_user("WiZ", {"hostname": "og.irc.developer"}) + assert client._format_user_mask("WiZ") == "WiZ!wiz@og.irc.developer" - client._sync_user('WiZ', { 'username': None }) - assert client._format_user_mask('WiZ') == 'WiZ!*@og.irc.developer' + await client._sync_user("WiZ", {"username": None}) + assert client._format_user_mask("WiZ") == "WiZ!*@og.irc.developer" diff --git a/tests/test_featurize.py b/tests/test_featurize.py index b675c27..7290955 100644 --- a/tests/test_featurize.py +++ b/tests/test_featurize.py @@ -1,5 +1,6 @@ +import pytest + import pydle -from .mocks import MockClient from .fixtures import with_client @@ -10,43 +11,61 @@ def run(): return with_client(*features, connected=False)(f)() except TypeError as e: assert False, e + run.__name__ = f.__name__ return run + return inner + def assert_mro(client, *features): # Skip FeaturizedClient, MockClient, pydle.BasicClient and object classes. assert client.__class__.__mro__[2:-2] == features + class FeatureClass(pydle.BasicClient): pass + class SubFeatureClass(FeatureClass): pass + class SubBFeatureClass(FeatureClass): pass + class DiamondFeatureClass(SubBFeatureClass, SubFeatureClass): pass +@pytest.mark.asyncio @with_errorcheck_client() def test_featurize_basic(server, client): assert_mro(client) + +@pytest.mark.asyncio @with_errorcheck_client(FeatureClass) def test_featurize_multiple(server, client): assert_mro(client, FeatureClass) + +@pytest.mark.asyncio @with_errorcheck_client(SubFeatureClass) def test_featurize_inheritance(server, client): assert_mro(client, SubFeatureClass, FeatureClass) + +@pytest.mark.asyncio @with_errorcheck_client(FeatureClass, SubFeatureClass) def test_featurize_inheritance_ordering(server, client): assert_mro(client, SubFeatureClass, FeatureClass) + +@pytest.mark.asyncio @with_errorcheck_client(SubBFeatureClass, SubFeatureClass, DiamondFeatureClass) def test_featurize_inheritance_diamond(server, client): - assert_mro(client, DiamondFeatureClass, SubBFeatureClass, SubFeatureClass, FeatureClass) + assert_mro( + client, DiamondFeatureClass, SubBFeatureClass, SubFeatureClass, FeatureClass + ) diff --git a/tests/test_ircv3.py b/tests/test_ircv3.py index f99410f..0b43b99 100644 --- a/tests/test_ircv3.py +++ b/tests/test_ircv3.py @@ -2,21 +2,33 @@ from pydle.features import ircv3 -pytestmark = pytest.mark.unit +pytestmark = [pytest.mark.unit, pytest.mark.ircv3] @pytest.mark.parametrize( "payload, expected", [ ( - rb"@+example=raw+:=,escaped\:\s\\ :irc.example.com NOTICE #channel :Message", - {"+example": """raw+:=,escaped; \\"""} + rb"@empty=;missing :irc.example.com NOTICE #channel :Message", + {"empty": True, "missing": True}, ), ( - rb"@+example=\foo\bar :irc.example.com NOTICE #channel :Message", - {"+example": "foobar"} + rb"@+example=raw+:=,escaped\:\s\\ :irc.example.com NOTICE #channel :Message", + {"+example": """raw+:=,escaped; \\"""}, ), - ] + ( + rb"@+example=\foo\bar :irc.example.com NOTICE #channel :Message", + {"+example": "foobar"}, + ), + ( + rb"@msgid=796~1602221579~51;account=user123 :user123!user123@(ip) PRIVMSG #user123 :ping", + {"msgid": "796~1602221579~51", "account": "user123"}, + ), + ( + rb"@inspircd.org/service;inspircd.org/bot :ChanServ!services@services.(domain) MODE #user123 +qo user123 :user123", + {"inspircd.org/service": True, r"inspircd.org/bot": True}, + ), + ], ) def test_tagged_message_escape_sequences(payload, expected): message = ircv3.tags.TaggedMessage.parse(payload) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 0000000..c9a38d5 --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,14 @@ +""" +test_misc.py ~ Testing of Misc. Functions + +Designed for those simple functions that don't need their own dedicated test files +But we want to hit them anyways +""" +from pydle.protocol import identifierify + + +def test_identifierify(): + good_name = identifierify("MyVerySimpleName") + bad_name = identifierify("I'mASpec!äl/Name!_") + assert good_name == "myverysimplename" + assert bad_name == "i_maspec__l_name__"