From 35185d5d8910367eae49716f77e07f8922687924 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Mon, 21 Aug 2023 12:56:04 +0200 Subject: [PATCH] feat(client): implement a listener system for disnake.Client (#1066) --- changelog/975.feature.rst | 1 + disnake/client.py | 165 +++++++++++++++++++++++- disnake/ext/commands/common_bot_base.py | 163 +---------------------- docs/api/clients.rst | 5 +- docs/api/events.rst | 58 +++++---- docs/ext/commands/api/bots.rst | 2 +- tests/test_events.py | 65 +++++----- 7 files changed, 239 insertions(+), 220 deletions(-) create mode 100644 changelog/975.feature.rst diff --git a/changelog/975.feature.rst b/changelog/975.feature.rst new file mode 100644 index 0000000000..b3cd727853 --- /dev/null +++ b/changelog/975.feature.rst @@ -0,0 +1 @@ +Move the event listener system implementation from :class:`.ext.commands.Bot` to :class:`.Client`, making Clients able to have more than one listener per event type. diff --git a/disnake/client.py b/disnake/client.py index 1b1ca58888..3584f5febf 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -7,6 +7,7 @@ import signal import sys import traceback +import types import warnings from datetime import datetime, timedelta from errno import ECONNRESET @@ -19,6 +20,7 @@ Generator, List, Literal, + Mapping, NamedTuple, Optional, Sequence, @@ -44,7 +46,7 @@ from .backoff import ExponentialBackoff from .channel import PartialMessageable, _threaded_channel_factory from .emoji import Emoji -from .enums import ApplicationCommandType, ChannelType, Status +from .enums import ApplicationCommandType, ChannelType, Event, Status from .errors import ( ConnectionClosed, GatewayNotFound, @@ -81,7 +83,6 @@ from .app_commands import APIApplicationCommand from .asset import AssetBytes from .channel import DMChannel - from .enums import Event from .member import Member from .message import Message from .types.application_role_connection import ( @@ -97,6 +98,11 @@ "GatewayParams", ) +T = TypeVar("T") + +Coro = Coroutine[Any, Any, T] +CoroFunc = Callable[..., Coro[Any]] + CoroT = TypeVar("CoroT", bound=Callable[..., Coroutine[Any, Any, Any]]) _log = logging.getLogger(__name__) @@ -454,6 +460,8 @@ def __init__( if self.gateway_params.encoding != "json": raise ValueError("Gateway encodings other than `json` are currently not supported.") + self.extra_events: Dict[str, List[CoroFunc]] = {} + # internals def _get_websocket( @@ -762,6 +770,159 @@ def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None: else: self._schedule_event(coro, method, *args, **kwargs) + for event_ in self.extra_events.get(method, []): + self._schedule_event(event_, method, *args, **kwargs) + + def add_listener(self, func: CoroFunc, name: Union[str, Event] = MISSING) -> None: + """The non decorator alternative to :meth:`.listen`. + + .. versionchanged:: 2.10 + The definition of this method was moved from :class:`.ext.commands.Bot` + to the :class:`.Client` class. + + Parameters + ---------- + func: :ref:`coroutine ` + The function to call. + name: Union[:class:`str`, :class:`.Event`] + The name of the event to listen for. Defaults to ``func.__name__``. + + Example + -------- + + .. code-block:: python + + async def on_ready(): pass + async def my_message(message): pass + async def another_message(message): pass + + client.add_listener(on_ready) + client.add_listener(my_message, 'on_message') + client.add_listener(another_message, Event.message) + + Raises + ------ + TypeError + The function is not a coroutine or a string or an :class:`.Event` was not passed + as the name. + """ + if name is not MISSING and not isinstance(name, (str, Event)): + raise TypeError( + f"add_listener expected str or Enum but received {name.__class__.__name__!r} instead." + ) + + name_ = ( + func.__name__ + if name is MISSING + else (name if isinstance(name, str) else f"on_{name.value}") + ) + + if not asyncio.iscoroutinefunction(func): + raise TypeError("Listeners must be coroutines") + + if name_ in self.extra_events: + self.extra_events[name_].append(func) + else: + self.extra_events[name_] = [func] + + def remove_listener(self, func: CoroFunc, name: Union[str, Event] = MISSING) -> None: + """Removes a listener from the pool of listeners. + + .. versionchanged:: 2.10 + The definition of this method was moved from :class:`.ext.commands.Bot` + to the :class:`.Client` class. + + Parameters + ---------- + func + The function that was used as a listener to remove. + name: Union[:class:`str`, :class:`.Event`] + The name of the event we want to remove. Defaults to + ``func.__name__``. + + Raises + ------ + TypeError + The name passed was not a string or an :class:`.Event`. + """ + if name is not MISSING and not isinstance(name, (str, Event)): + raise TypeError( + f"remove_listener expected str or Enum but received {name.__class__.__name__!r} instead." + ) + name = ( + func.__name__ + if name is MISSING + else (name if isinstance(name, str) else f"on_{name.value}") + ) + + if name in self.extra_events: + try: + self.extra_events[name].remove(func) + except ValueError: + pass + + def listen(self, name: Union[str, Event] = MISSING) -> Callable[[CoroT], CoroT]: + """A decorator that registers another function as an external + event listener. Basically this allows you to listen to multiple + events from different places e.g. such as :func:`.on_ready` + + The functions being listened to must be a :ref:`coroutine `. + + .. versionchanged:: 2.10 + The definition of this method was moved from :class:`.ext.commands.Bot` + to the :class:`.Client` class. + + Example + ------- + .. code-block:: python3 + + @client.listen() + async def on_message(message): + print('one') + + # in some other file... + + @client.listen('on_message') + async def my_message(message): + print('two') + + # in yet another file + @client.listen(Event.message) + async def another_message(message): + print('three') + + Would print one, two and three in an unspecified order. + + Raises + ------ + TypeError + The function being listened to is not a coroutine or a string or an :class:`.Event` was not passed + as the name. + """ + if name is not MISSING and not isinstance(name, (str, Event)): + raise TypeError( + f"listen expected str or Enum but received {name.__class__.__name__!r} instead." + ) + + def decorator(func: CoroT) -> CoroT: + self.add_listener(func, name) + return func + + return decorator + + def get_listeners(self) -> Mapping[str, List[CoroFunc]]: + """Mapping[:class:`str`, List[Callable]]: A read-only mapping of event names to listeners. + + .. note:: + To add or remove a listener you should use :meth:`.add_listener` and + :meth:`.remove_listener`. + + .. versionchanged:: 2.10 + The definition of this method was moved from :class:`.ext.commands.Bot` + to the :class:`.Client` class. + """ + return types.MappingProxyType(self.extra_events) + async def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: """|coro| diff --git a/disnake/ext/commands/common_bot_base.py b/disnake/ext/commands/common_bot_base.py index 7f8b22d24b..dc4e81c97c 100644 --- a/disnake/ext/commands/common_bot_base.py +++ b/disnake/ext/commands/common_bot_base.py @@ -10,23 +10,10 @@ import sys import time import types -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - Generic, - List, - Mapping, - Optional, - Set, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Any, Dict, Generic, List, Mapping, Optional, Set, TypeVar, Union import disnake import disnake.utils -from disnake.enums import Event from . import errors from .cog import Cog @@ -54,6 +41,9 @@ def _is_submodule(parent: str, child: str) -> bool: class CommonBotBase(Generic[CogT]): + if TYPE_CHECKING: + extra_events: Dict[str, List[CoroFunc]] + def __init__( self, *args: Any, @@ -64,7 +54,6 @@ def __init__( ) -> None: self.__cogs: Dict[str, Cog] = {} self.__extensions: Dict[str, types.ModuleType] = {} - self.extra_events: Dict[str, List[CoroFunc]] = {} self._is_closed: bool = False self.owner_id: Optional[int] = owner_id @@ -82,12 +71,10 @@ def __init__( super().__init__(*args, **kwargs) + # FIXME: make event name pos-only or remove entirely in v3.0 def dispatch(self, event_name: str, *args: Any, **kwargs: Any) -> None: # super() will resolve to Client super().dispatch(event_name, *args, **kwargs) # type: ignore - ev = "on_" + event_name - for event in self.extra_events.get(ev, []): - self._schedule_event(event, ev, *args, **kwargs) # type: ignore async def _fill_owners(self) -> None: if self.owner_id or self.owner_ids: @@ -168,135 +155,6 @@ async def is_owner(self, user: Union[disnake.User, disnake.Member]) -> bool: self.owner_id = owner_id = app.owner.id return user.id == owner_id - # listener registration - - def add_listener(self, func: CoroFunc, name: Union[str, Event] = MISSING) -> None: - """The non decorator alternative to :meth:`.listen`. - - Parameters - ---------- - func: :ref:`coroutine ` - The function to call. - name: Union[:class:`str`, :class:`.Event`] - The name of the event to listen for. Defaults to ``func.__name__``. - - Example - -------- - - .. code-block:: python - - async def on_ready(): pass - async def my_message(message): pass - async def another_message(message): pass - - bot.add_listener(on_ready) - bot.add_listener(my_message, 'on_message') - bot.add_listener(another_message, Event.message) - - Raises - ------ - TypeError - The function is not a coroutine or a string or an :class:`.Event` was not passed - as the name. - """ - if name is not MISSING and not isinstance(name, (str, Event)): - raise TypeError( - f"Bot.add_listener expected str or Enum but received {name.__class__.__name__!r} instead." - ) - - name_ = ( - func.__name__ - if name is MISSING - else (name if isinstance(name, str) else f"on_{name.value}") - ) - - if not asyncio.iscoroutinefunction(func): - raise TypeError("Listeners must be coroutines") - - if name_ in self.extra_events: - self.extra_events[name_].append(func) - else: - self.extra_events[name_] = [func] - - def remove_listener(self, func: CoroFunc, name: Union[str, Event] = MISSING) -> None: - """Removes a listener from the pool of listeners. - - Parameters - ---------- - func - The function that was used as a listener to remove. - name: Union[:class:`str`, :class:`.Event`] - The name of the event we want to remove. Defaults to - ``func.__name__``. - - Raises - ------ - TypeError - The name passed was not a string or an :class:`.Event`. - """ - if name is not MISSING and not isinstance(name, (str, Event)): - raise TypeError( - f"Bot.remove_listener expected str or Enum but received {name.__class__.__name__!r} instead." - ) - name = ( - func.__name__ - if name is MISSING - else (name if isinstance(name, str) else f"on_{name.value}") - ) - - if name in self.extra_events: - try: - self.extra_events[name].remove(func) - except ValueError: - pass - - def listen(self, name: Union[str, Event] = MISSING) -> Callable[[CFT], CFT]: - """A decorator that registers another function as an external - event listener. Basically this allows you to listen to multiple - events from different places e.g. such as :func:`.on_ready` - - The functions being listened to must be a :ref:`coroutine `. - - Example - ------- - .. code-block:: python3 - - @bot.listen() - async def on_message(message): - print('one') - - # in some other file... - - @bot.listen('on_message') - async def my_message(message): - print('two') - - # in yet another file - @bot.listen(Event.message) - async def another_message(message): - print('three') - - Would print one, two and three in an unspecified order. - - Raises - ------ - TypeError - The function being listened to is not a coroutine or a string or an :class:`.Event` was not passed - as the name. - """ - if name is not MISSING and not isinstance(name, (str, Event)): - raise TypeError( - f"Bot.listen expected str or Enum but received {name.__class__.__name__!r} instead." - ) - - def decorator(func: CFT) -> CFT: - self.add_listener(func, name) - return func - - return decorator - - # cogs - def add_cog(self, cog: Cog, *, override: bool = False) -> None: """Adds a "cog" to the bot. @@ -395,17 +253,6 @@ def cogs(self) -> Mapping[str, Cog]: """Mapping[:class:`str`, :class:`Cog`]: A read-only mapping of cog name to cog.""" return types.MappingProxyType(self.__cogs) - def get_listeners(self) -> Mapping[str, List[CoroFunc]]: - """Mapping[:class:`str`, List[Callable]]: A read-only mapping of event names to listeners. - - .. note:: - To add or remove a listener you should use :meth:`.add_listener` and - :meth:`.remove_listener`. - - .. versionadded:: 2.9 - """ - return types.MappingProxyType(self.extra_events) - # extensions def _remove_module_references(self, name: str) -> None: diff --git a/docs/api/clients.rst b/docs/api/clients.rst index b608259013..7f00a43e48 100644 --- a/docs/api/clients.rst +++ b/docs/api/clients.rst @@ -20,7 +20,7 @@ Client .. autoclass:: Client :members: - :exclude-members: fetch_guilds, event + :exclude-members: fetch_guilds, event, listen .. automethod:: Client.event() :decorator: @@ -28,6 +28,9 @@ Client .. automethod:: Client.fetch_guilds :async-for: + .. automethod:: Client.listen(name=None) + :decorator: + AutoShardedClient ~~~~~~~~~~~~~~~~~ diff --git a/docs/api/events.rst b/docs/api/events.rst index 7df9545f49..c92762c3b0 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -16,9 +16,11 @@ So, what are events anyway? Most of the :class:`Client` application cycle is bas to notify client about certain actions like message deletion, emoji creation, member nickname updates, etc. This library provides a few ways to register an -*event handler* — a special function which will listen for specific types of events — which allows you to take action based on certain events. +*event handler* or *event listener* — a special function which will listen for specific types of events — which allows you to take action based on certain events. -The first way is through the use of the :meth:`Client.event` decorator: :: +The first way to create an *event handler* is through the use of the :meth:`Client.event` decorator. +Note that these are unique, which means you can only have one of +each type (i.e. only one ``on_message``, one ``on_member_ban``, etc.): :: client = disnake.Client(...) @@ -30,8 +32,9 @@ The first way is through the use of the :meth:`Client.event` decorator: :: if message.content.startswith('$hello'): await message.reply(f'Hello, {message.author}!') -The second way is through subclassing :class:`Client` and -overriding the specific events. For example: :: + +Another way is through subclassing :class:`Client` and overriding the specific events, +which has essentially the same effect as the :meth:`Client.event` decorator. For example: :: class MyClient(disnake.Client): async def on_message(self, message): @@ -41,7 +44,28 @@ overriding the specific events. For example: :: if message.content.startswith('$hello'): await message.reply(f'Hello, {message.author}!') -Another way is to use :meth:`Client.wait_for`, which is a single-use event handler to wait for + +A separate way is through the use of an *event listener*. These are similar to the *event handlers* +described above, but allow you to have as many *listeners* of the same type as you want. +You can register listeners using the :meth:`Client.listen` decorator or through the :meth:`Client.add_listener` +method. Similarly you can remove a listener using the :meth:`Client.remove_listener` method. :: + + @client.listen() + async def on_message(message: disnake.Message): + if message.author.bot: + return + + if message.content.startswith('$hello'): + await message.reply(f'Hello, {message.author}') + + + async def my_on_ready(): + print(f'Logged in as {client.user}') + + client.add_listener(my_on_ready, 'on_ready') + + +Lastly, :meth:`Client.wait_for` is a single-use event handler to wait for something to happen in more specific scenarios: :: @client.event @@ -57,20 +81,6 @@ something to happen in more specific scenarios: :: msg = await client.wait_for('message', check=check) await channel.send(f'Hello {msg.author}!') -The above pieces of code are essentially equal, and both respond with ``Hello, {author's username here}!`` message -when a user sends a ``$hello`` message. - -.. warning:: - - Event handlers described here are a bit different from :class:`~ext.commands.Bot`'s *event listeners*. - - :class:`Client`'s event handlers are unique, which means you can only have one of each type (i.e. only one `on_message`, one `on_member_ban`, etc.). With :class:`~ext.commands.Bot` however, you can have as many *listeners* - of the same type as you want. - - Also note that :meth:`Bot.event() ` is the same as :class:`Client`'s - :meth:`~Client.event` (since :class:`~ext.commands.Bot` subclasses :class:`Client`) and does not allow to listen/watch - for multiple events of the same type. Consider using :meth:`Bot.listen() ` instead. - .. note:: Events can be sent not only by Discord. For instance, if you use the :ref:`commands extension `, @@ -126,9 +136,8 @@ This section documents events related to :class:`Client` and its connectivity to ``on_error`` will only be dispatched to :meth:`Client.event`. - It will not be received by :meth:`Client.wait_for`, or, if used, - :ref:`ext_commands_api_bots` listeners such as - :meth:`~ext.commands.Bot.listen` or :meth:`~ext.commands.Cog.listener`. + It will not be received by :meth:`Client.wait_for` and listeners + such as :meth:`Client.listen`, or :meth:`~ext.commands.Cog.listener`. :param event: The name of the event that raised the exception. :type event: :class:`str` @@ -154,9 +163,8 @@ This section documents events related to :class:`Client` and its connectivity to .. note:: ``on_gateway_error`` will only be dispatched to :meth:`Client.event`. - It will not be received by :meth:`Client.wait_for`, or, if used, - :ref:`ext_commands_api_bots` listeners such as - :meth:`~ext.commands.Bot.listen` or :meth:`~ext.commands.Cog.listener`. + It will not be received by :meth:`Client.wait_for` and listeners + such as :meth:`Client.listen`, or :meth:`~ext.commands.Cog.listener`. .. note:: This will not be dispatched for exceptions that occur while parsing ``READY`` and diff --git a/docs/ext/commands/api/bots.rst b/docs/ext/commands/api/bots.rst index fef065fdbb..87976ccbc1 100644 --- a/docs/ext/commands/api/bots.rst +++ b/docs/ext/commands/api/bots.rst @@ -21,7 +21,7 @@ Bot .. autoclass:: Bot :members: :inherited-members: - :exclude-members: after_invoke, before_invoke, check, check_once, command, event, group, listen, slash_command, user_command, message_command, after_slash_command_invoke, after_user_command_invoke, after_message_command_invoke, before_slash_command_invoke, before_user_command_invoke, before_message_command_invoke + :exclude-members: after_invoke, before_invoke, check, check_once, command, event, listen, group, slash_command, user_command, message_command, after_slash_command_invoke, after_user_command_invoke, after_message_command_invoke, before_slash_command_invoke, before_user_command_invoke, before_message_command_invoke .. automethod:: Bot.after_invoke() :decorator: diff --git a/tests/test_events.py b/tests/test_events.py index 14e6a649c4..15cc467151 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,5 +1,4 @@ # SPDX-License-Identifier: MIT - from typing import Any import pytest @@ -8,9 +7,8 @@ from disnake import Event from disnake.ext import commands -# n.b. the specific choice of events used in this file is irrelevant - +# n.b. the specific choice of events used in this file is irrelevant @pytest.fixture def client(): return disnake.Client() @@ -24,78 +22,79 @@ def bot(): ) +@pytest.fixture(params=["client", "bot"]) +def client_or_bot(request): + return request.getfixturevalue(request.param) + + # @Client.event -def test_client_event(client: disnake.Client) -> None: - assert not hasattr(client, "on_message_edit") +def test_event(client_or_bot: disnake.Client) -> None: + assert not hasattr(client_or_bot, "on_message_edit") - @client.event + @client_or_bot.event async def on_message_edit(self, *args: Any) -> None: ... - assert client.on_message_edit is on_message_edit # type: ignore + assert client_or_bot.on_message_edit is on_message_edit # type: ignore -# Bot.wait_for +# Client.wait_for @pytest.mark.parametrize("event", ["thread_create", Event.thread_create]) -def test_wait_for(bot: commands.Bot, event) -> None: - coro = bot.wait_for(event) - assert len(bot._listeners["thread_create"]) == 1 +def test_wait_for(client_or_bot: disnake.Client, event) -> None: + coro = client_or_bot.wait_for(event) + assert len(client_or_bot._listeners["thread_create"]) == 1 coro.close() # close coroutine to avoid warning -# Bot.add_listener / Bot.remove_listener +# Client.add_listener / Client.remove_listener @pytest.mark.parametrize("event", ["on_guild_remove", Event.guild_remove]) -def test_addremove_listener(bot: commands.Bot, event) -> None: +def test_addremove_listener(client_or_bot: disnake.Client, event) -> None: async def callback(self, *args: Any) -> None: ... - bot.add_listener(callback, event) - assert len(bot.extra_events["on_guild_remove"]) == 1 - - bot.remove_listener(callback, event) - assert len(bot.extra_events["on_guild_remove"]) == 0 + client_or_bot.add_listener(callback, event) + assert len(client_or_bot.extra_events["on_guild_remove"]) == 1 + client_or_bot.remove_listener(callback, event) + assert len(client_or_bot.extra_events["on_guild_remove"]) == 0 -def test_addremove_listener__implicit(bot: commands.Bot) -> None: +def test_addremove_listener__implicit(client_or_bot: disnake.Client) -> None: async def on_guild_remove(self, *args: Any) -> None: ... - bot.add_listener(on_guild_remove) - assert len(bot.extra_events["on_guild_remove"]) == 1 - - bot.remove_listener(on_guild_remove) - assert len(bot.extra_events["on_guild_remove"]) == 0 + client_or_bot.add_listener(on_guild_remove) + assert len(client_or_bot.extra_events["on_guild_remove"]) == 1 + client_or_bot.remove_listener(on_guild_remove) + assert len(client_or_bot.extra_events["on_guild_remove"]) == 0 -# @Bot.listen +# @Client.listen @pytest.mark.parametrize("event", ["on_guild_role_create", Event.guild_role_create]) -def test_listen(bot: commands.Bot, event) -> None: - @bot.listen(event) +def test_listen(client_or_bot: disnake.Client, event) -> None: + @client_or_bot.listen(event) async def callback(self, *args: Any) -> None: ... - assert len(bot.extra_events["on_guild_role_create"]) == 1 + assert len(client_or_bot.extra_events["on_guild_role_create"]) == 1 -def test_listen__implicit(bot: commands.Bot) -> None: - @bot.listen() +def test_listen__implicit(client_or_bot: disnake.Client) -> None: + @client_or_bot.listen() async def on_guild_role_create(self, *args: Any) -> None: ... - assert len(bot.extra_events["on_guild_role_create"]) == 1 + assert len(client_or_bot.extra_events["on_guild_role_create"]) == 1 # @commands.Cog.listener - - @pytest.mark.parametrize("event", ["on_automod_rule_update", Event.automod_rule_update]) def test_listener(bot: commands.Bot, event) -> None: class Cog(commands.Cog):