diff --git a/changelog/1132.breaking.0.rst b/changelog/1132.breaking.0.rst new file mode 100644 index 0000000000..1bf40fb3df --- /dev/null +++ b/changelog/1132.breaking.0.rst @@ -0,0 +1 @@ +Removed the ``loop`` and ``asyncio_debug`` parameters from :class:`Client`. diff --git a/changelog/1132.breaking.1.rst b/changelog/1132.breaking.1.rst new file mode 100644 index 0000000000..591f9ab583 --- /dev/null +++ b/changelog/1132.breaking.1.rst @@ -0,0 +1 @@ +The majority of the library now assumes that there is an :mod:`asyncio` event loop running. diff --git a/changelog/1132.deprecate.rst b/changelog/1132.deprecate.rst new file mode 100644 index 0000000000..21dfbba89e --- /dev/null +++ b/changelog/1132.deprecate.rst @@ -0,0 +1 @@ +Deprecate :attr:`Client.loop`. Use :func:`asyncio.get_running_loop` instead. diff --git a/changelog/1132.feature.rst b/changelog/1132.feature.rst new file mode 100644 index 0000000000..32470148e2 --- /dev/null +++ b/changelog/1132.feature.rst @@ -0,0 +1 @@ +Add :meth:`Client.setup_hook`. diff --git a/changelog/1132.misc.rst b/changelog/1132.misc.rst new file mode 100644 index 0000000000..9d24d2d728 --- /dev/null +++ b/changelog/1132.misc.rst @@ -0,0 +1 @@ +:meth:`Client.run` now uses :func:`asyncio.run` under-the-hood instead of custom runner logic. diff --git a/changelog/641.breaking.0.rst b/changelog/641.breaking.0.rst new file mode 100644 index 0000000000..0bd51f1a58 --- /dev/null +++ b/changelog/641.breaking.0.rst @@ -0,0 +1 @@ +|commands| Make :meth:`Bot.load_extensions `, :meth:`Bot.load_extension `, :meth:`Bot.unload_extension `, :meth:`Bot.reload_extension `, :meth:`Bot.add_cog `, and :meth:`Bot.remove_cog ` asynchronous. diff --git a/changelog/641.breaking.1.rst b/changelog/641.breaking.1.rst new file mode 100644 index 0000000000..4d1477d95b --- /dev/null +++ b/changelog/641.breaking.1.rst @@ -0,0 +1 @@ +|commands| :meth:`Cog.cog_load ` is now called *after* the cog has finished loading. diff --git a/changelog/641.feature.0.rst b/changelog/641.feature.0.rst new file mode 100644 index 0000000000..66ed434e12 --- /dev/null +++ b/changelog/641.feature.0.rst @@ -0,0 +1 @@ +|commands| :meth:`Cog.cog_load ` and :meth:`Cog.cog_unload ` can now be either sync or async. diff --git a/changelog/641.feature.1.rst b/changelog/641.feature.1.rst new file mode 100644 index 0000000000..7a56ba23fe --- /dev/null +++ b/changelog/641.feature.1.rst @@ -0,0 +1 @@ +|commands| The ``setup`` and ``teardown`` functions utilized by :ref:`ext_commands_extensions` can now be asynchronous. diff --git a/disnake/client.py b/disnake/client.py index 97941911cc..a43b7ca37f 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -4,10 +4,10 @@ import asyncio import logging -import signal import sys import traceback import types +import warnings from collections.abc import Callable, Coroutine from datetime import timedelta from errno import ECONNRESET @@ -117,41 +117,6 @@ _log = logging.getLogger(__name__) -def _cancel_tasks(loop: asyncio.AbstractEventLoop) -> None: - tasks = {t for t in asyncio.all_tasks(loop=loop) if not t.done()} - - if not tasks: - return - - _log.info("Cleaning up after %d tasks.", len(tasks)) - for task in tasks: - task.cancel() - - loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) - _log.info("All tasks finished cancelling.") - - for task in tasks: - if task.cancelled(): - continue - if task.exception() is not None: - loop.call_exception_handler( - { - "message": "Unhandled exception during Client.run shutdown.", - "exception": task.exception(), - "task": task, - } - ) - - -def _cleanup_loop(loop: asyncio.AbstractEventLoop) -> None: - try: - _cancel_tasks(loop) - loop.run_until_complete(loop.shutdown_asyncgens()) - finally: - _log.info("Closing the event loop.") - loop.close() - - class SessionStartLimit: """A class that contains information about the current session start limit, at the time when the client connected for the first time. @@ -233,6 +198,9 @@ class Client: A number of options can be passed to the :class:`Client`. + .. versionchanged:: 3.0 + The ``asyncio_debug`` parameter has been removed. Use :meth:`asyncio.loop.set_debug` directly. + Parameters ---------- max_messages: :class:`int` | :data:`None` @@ -241,13 +209,6 @@ class Client: .. versionchanged:: 1.3 Allow disabling the message cache and change the default size to ``1000``. - loop: :class:`asyncio.AbstractEventLoop` | :data:`None` - The :class:`asyncio.AbstractEventLoop` to use for asynchronous operations. - Defaults to :data:`None`, in which case the current event loop is - used, or a new loop is created if there is none. - asyncio_debug: :class:`bool` - Whether to enable asyncio debugging when the client starts. - Defaults to False. connector: :class:`aiohttp.BaseConnector` | :data:`None` The connector to use for connection pooling. proxy: :class:`str` | :data:`None` @@ -365,8 +326,6 @@ class Client: ---------- ws The websocket gateway the client is currently connected to. Could be :data:`None`. - loop: :class:`asyncio.AbstractEventLoop` - The event loop that the client uses for asynchronous operations. session_start_limit: :class:`SessionStartLimit` | :data:`None` Information about the current session start limit. Only available after initiating the connection. @@ -382,8 +341,6 @@ class Client: def __init__( self, *, - asyncio_debug: bool = False, - loop: asyncio.AbstractEventLoop | None = None, shard_id: int | None = None, shard_count: int | None = None, enable_debug_events: bool = False, @@ -409,21 +366,26 @@ def __init__( # self.ws is set in the connect method self.ws: DiscordWebSocket = None # pyright: ignore[reportAttributeAccessIssue] - if loop is None: - self.loop: asyncio.AbstractEventLoop = utils.get_event_loop() - else: - self.loop: asyncio.AbstractEventLoop = loop - - self.loop.set_debug(asyncio_debug) self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = {} self.session_start_limit: SessionStartLimit | None = None + if connector: + try: + asyncio.get_running_loop() + except RuntimeError: + msg = ( + "`connector` was created outside of an asyncio loop, which will likely cause" + "issues later down the line due to the client and `connector` running on" + "different asyncio loops; consider moving client instantiation to an '`async" + "main`' function and then manually asyncio.run it" + ) + raise RuntimeError(msg) from None + self.http: HTTPClient = HTTPClient( connector, proxy=proxy, proxy_auth=proxy_auth, unsync_clock=assume_unsync_clock, - loop=self.loop, ) self._handlers: dict[str, Callable[..., Any]] = { @@ -510,7 +472,6 @@ def _get_state( handlers=self._handlers, hooks=self._hooks, http=self.http, - loop=self.loop, max_messages=max_messages, application_id=application_id, heartbeat_timeout=heartbeat_timeout, @@ -531,6 +492,29 @@ def _handle_first_connect(self) -> None: return self._first_connect.set() + @property + def loop(self): + """:class:`asyncio.AbstractEventLoop`: Same as :func:`asyncio.get_running_loop`. + + .. deprecated:: 3.0 + Use :func:`asyncio.get_running_loop` directly. + """ + warnings.warn( + "Accessing `Client.loop` is deprecated. Use `asyncio.get_running_loop()` instead.", + category=DeprecationWarning, + stacklevel=2, + ) + return asyncio.get_running_loop() + + @loop.setter + def loop(self, value: asyncio.AbstractEventLoop) -> None: + warnings.warn( + "Assigning to `Client.loop` is deprecated. Use `asyncio.set_event_loop()` instead.", + category=DeprecationWarning, + stacklevel=2, + ) + asyncio.set_event_loop(value) + @property def latency(self) -> float: """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. @@ -1015,12 +999,34 @@ async def before_identify_hook(self, shard_id: int | None, *, initial: bool = Fa if not initial: await asyncio.sleep(5.0) + async def setup_hook(self) -> None: + """A hook that allows you to perform asynchronous setup like + initiating database connections or loading cogs/extensions after + the bot has logged in but before it has connected to the websocket. + + This is only called once, in :meth:`.login`, before any events are + dispatched, making it a better solution than doing such setup in + the :func:`disnake.on_ready` event. + + .. warning:: + Since this is called *before* the websocket connection is made, + anything that waits for the websocket will deadlock, which includes + methods like :meth:`.wait_for`, :meth:`.wait_until_ready` + and :meth:`.wait_until_first_connect`. + + .. versionadded:: 3.0 + """ + # login state management async def login(self, token: str) -> None: """|coro| - Logs in the client with the specified credentials. + Logs in the client with the specified credentials and calls + :meth:`.setup_hook`. + + .. versionchanged:: 3.0 + Now also calls :meth:`.setup_hook`. Parameters ---------- @@ -1045,6 +1051,8 @@ async def login(self, token: str) -> None: data = await self.http.static_login(token.strip()) self._connection.user = ClientUser(state=self._connection, data=data) + await self.setup_hook() + async def connect( self, *, reconnect: bool = True, ignore_session_start_limit: bool = False ) -> None: @@ -1259,62 +1267,26 @@ def run(self, *args: Any, **kwargs: Any) -> None: function should not be used. Use :meth:`start` coroutine or :meth:`connect` + :meth:`login`. - Roughly Equivalent to: :: + Equivalent to: :: try: - loop.run_until_complete(start(*args, **kwargs)) + asyncio.run(start(*args, **kwargs)) except KeyboardInterrupt: - loop.run_until_complete(close()) - # cancel all tasks lingering - finally: - loop.close() + return .. warning:: - This function must be the last function to call due to the fact that it - is blocking. That means that registration of events or anything being - called after this function call will not execute until it returns + This function should be the last function to be called because it is blocking. + That means that registration of commands, events or any code after this function + call will not execute until it returns. - Parameters - ---------- - token: :class:`str` - The discord token of the bot that is being ran. + .. versionchanged:: 3.0 + Changed to use :func:`asyncio.run` instead of custom logic. """ - loop = self.loop - try: - loop.add_signal_handler(signal.SIGINT, lambda: loop.stop()) - loop.add_signal_handler(signal.SIGTERM, lambda: loop.stop()) - except NotImplementedError: - pass - - async def runner() -> None: - try: - await self.start(*args, **kwargs) - finally: - if not self.is_closed(): - await self.close() - - def stop_loop_on_completion(f) -> None: - loop.stop() - - future = asyncio.ensure_future(runner(), loop=loop) - future.add_done_callback(stop_loop_on_completion) - try: - loop.run_forever() + asyncio.run(self.start(*args, **kwargs)) except KeyboardInterrupt: - _log.info("Received signal to terminate bot and event loop.") - finally: - future.remove_done_callback(stop_loop_on_completion) - _log.info("Cleaning up tasks.") - _cleanup_loop(loop) - - if not future.cancelled(): - try: - future.result() - except KeyboardInterrupt: - # I am unsure why this gets raised here but suppress it anyway - pass + return # properties @@ -1750,6 +1722,9 @@ def wait_for( This function returns the **first event that meets the requirements**. + .. important:: + Requires an :mod:`asyncio` loop to be running. + Examples -------- Waiting for a user reply: :: @@ -1813,6 +1788,8 @@ def check(reaction, user): Raises ------ + RuntimeError + No running ``asyncio`` loop. asyncio.TimeoutError If a timeout is provided and it was reached. @@ -1823,7 +1800,7 @@ def check(reaction, user): arguments that mirrors the parameters passed in the :ref:`event `. """ - future = self.loop.create_future() + future = asyncio.get_running_loop().create_future() if check is None: def _check(*args) -> bool: diff --git a/disnake/context_managers.py b/disnake/context_managers.py index 3d956200e6..c35b7bcdc6 100644 --- a/disnake/context_managers.py +++ b/disnake/context_managers.py @@ -26,7 +26,6 @@ def _typing_done_callback(fut: asyncio.Future) -> None: class Typing: def __init__(self, messageable: Messageable | ThreadOnlyGuildChannel) -> None: - self.loop: asyncio.AbstractEventLoop = messageable._state.loop self.messageable: Messageable | ThreadOnlyGuildChannel = messageable async def do_typing(self) -> None: @@ -42,7 +41,7 @@ async def do_typing(self) -> None: await asyncio.sleep(5) def __enter__(self) -> Self: - self.task: asyncio.Task = self.loop.create_task(self.do_typing()) + self.task: asyncio.Task = asyncio.create_task(self.do_typing()) self.task.add_done_callback(_typing_done_callback) return self diff --git a/disnake/ext/commands/bot.py b/disnake/ext/commands/bot.py index 0a756986b3..efaaf9221d 100644 --- a/disnake/ext/commands/bot.py +++ b/disnake/ext/commands/bot.py @@ -10,7 +10,6 @@ from .interaction_bot_base import InteractionBotBase if TYPE_CHECKING: - import asyncio from collections.abc import Callable, Sequence import aiohttp @@ -268,7 +267,6 @@ def __init__( default_install_types: ApplicationInstallTypes | None = None, default_contexts: InteractionContextTypes | None = None, asyncio_debug: bool = False, - loop: asyncio.AbstractEventLoop | None = None, shard_id: int | None = None, shard_count: int | None = None, enable_debug_events: bool = False, @@ -321,7 +319,6 @@ def __init__( default_install_types: ApplicationInstallTypes | None = None, default_contexts: InteractionContextTypes | None = None, asyncio_debug: bool = False, - loop: asyncio.AbstractEventLoop | None = None, shard_ids: list[int] | None = None, # instead of shard_id shard_count: int | None = None, enable_debug_events: bool = False, @@ -493,7 +490,6 @@ def __init__( default_install_types: ApplicationInstallTypes | None = None, default_contexts: InteractionContextTypes | None = None, asyncio_debug: bool = False, - loop: asyncio.AbstractEventLoop | None = None, shard_id: int | None = None, shard_count: int | None = None, enable_debug_events: bool = False, @@ -539,7 +535,6 @@ def __init__( default_install_types: ApplicationInstallTypes | None = None, default_contexts: InteractionContextTypes | None = None, asyncio_debug: bool = False, - loop: asyncio.AbstractEventLoop | None = None, shard_ids: list[int] | None = None, # instead of shard_id shard_count: int | None = None, enable_debug_events: bool = False, diff --git a/disnake/ext/commands/bot_base.py b/disnake/ext/commands/bot_base.py index 8b8b160c6f..3cc040136a 100644 --- a/disnake/ext/commands/bot_base.py +++ b/disnake/ext/commands/bot_base.py @@ -401,8 +401,8 @@ def after_invoke(self, coro: CFT) -> CFT: # extensions - def _remove_module_references(self, name: str) -> None: - super()._remove_module_references(name) + async def _remove_module_references(self, name: str) -> None: + await super()._remove_module_references(name) # remove all the commands from the module for cmd in self.all_commands.copy().values(): if cmd.module and _is_submodule(name, cmd.module): diff --git a/disnake/ext/commands/cog.py b/disnake/ext/commands/cog.py index c3abb256f4..e5d6557f44 100644 --- a/disnake/ext/commands/cog.py +++ b/disnake/ext/commands/cog.py @@ -464,17 +464,21 @@ def has_message_error_handler(self) -> bool: @_cog_special_method async def cog_load(self) -> None: - """A special method that is called as a task when the cog is added.""" + r"""A special method that is called when the cog is added. + + .. versionchanged:: 3.0 + This is now ``await``\ed directly instead of being scheduled as a task. + This means that this is also now guaranteed to run after the cog has + fully finished loading. + """ pass @_cog_special_method - def cog_unload(self) -> None: + async def cog_unload(self) -> None: """A special method that is called when the cog gets removed. - This function **cannot** be a coroutine. It must be a regular - function. - - Subclasses must replace this if they want special unloading behaviour. + .. versionchanged:: 3.0 + This can now be a coroutine. """ pass @@ -714,7 +718,7 @@ async def cog_after_message_command_invoke(self, inter: ApplicationCommandIntera """Similar to :meth:`cog_after_slash_command_invoke` but for message commands.""" pass - def _inject(self, bot: AnyBot) -> Self: + async def _inject(self, bot: AnyBot) -> Self: from .bot import AutoShardedInteractionBot, InteractionBot cls = self.__class__ @@ -762,9 +766,6 @@ def _inject(self, bot: AnyBot) -> Self: bot.remove_message_command(to_undo.name) raise - if not hasattr(self.cog_load.__func__, "__cog_special_method__"): - bot.loop.create_task(disnake.utils.maybe_coroutine(self.cog_load)) - # check if we're overriding the default if cls.bot_check is not Cog.bot_check: if isinstance(bot, (InteractionBot, AutoShardedInteractionBot)): @@ -823,9 +824,12 @@ def _inject(self, bot: AnyBot) -> Self: except NotImplementedError: pass + if not hasattr(self.cog_load.__func__, "__cog_special_method__"): + await disnake.utils.maybe_coroutine(self.cog_load) + return self - def _eject(self, bot: AnyBot) -> None: + async def _eject(self, bot: AnyBot) -> None: cls = self.__class__ try: @@ -889,7 +893,7 @@ def _eject(self, bot: AnyBot) -> None: except NotImplementedError: pass try: - self.cog_unload() + await disnake.utils.maybe_coroutine(self.cog_unload) except Exception as e: _log.error( "An error occurred while unloading the %s cog.", self.qualified_name, exc_info=e diff --git a/disnake/ext/commands/common_bot_base.py b/disnake/ext/commands/common_bot_base.py index 4626674922..53a18aeac7 100644 --- a/disnake/ext/commands/common_bot_base.py +++ b/disnake/ext/commands/common_bot_base.py @@ -11,6 +11,7 @@ import sys import time import types +from functools import partial from typing import TYPE_CHECKING, Any, Generic, TypeVar import disnake @@ -98,14 +99,14 @@ async def close(self) -> None: for extension in tuple(self.__extensions): try: - self.unload_extension(extension) + await self.unload_extension(extension) except Exception as error: error.__suppress_context__ = True _log.error("Failed to unload extension %r", extension, exc_info=error) for cog in tuple(self.__cogs): try: - self.remove_cog(cog) + await self.remove_cog(cog) except Exception as error: error.__suppress_context__ = True _log.exception("Failed to remove cog %r", cog, exc_info=error) @@ -116,12 +117,11 @@ async def close(self) -> None: async def login(self, token: str) -> None: await super().login(token=token) # pyright: ignore[reportAttributeAccessIssue] - loop: asyncio.AbstractEventLoop = self.loop # pyright: ignore[reportAttributeAccessIssue] if self.reload: - loop.create_task(self._watchdog()) + asyncio.create_task(self._watchdog()) # prefetch - loop.create_task(self._fill_owners()) + asyncio.create_task(self._fill_owners()) async def is_owner(self, user: disnake.User | disnake.Member) -> bool: """|coro| @@ -158,7 +158,7 @@ async def is_owner(self, user: disnake.User | disnake.Member) -> bool: else: return user.id in self.owner_ids - def add_cog(self, cog: Cog, *, override: bool = False) -> None: + async def add_cog(self, cog: Cog, *, override: bool = False) -> None: """Adds a "cog" to the bot. A cog is a class that has its own event listeners and commands. @@ -172,6 +172,9 @@ def add_cog(self, cog: Cog, *, override: bool = False) -> None: :exc:`.ClientException` is raised when a cog with the same name is already loaded. + .. versionchanged:: 3.0 + This is now a coroutine. + Parameters ---------- cog: :class:`.Cog` @@ -202,10 +205,10 @@ def add_cog(self, cog: Cog, *, override: bool = False) -> None: if not override: msg = f"Cog named {cog_name!r} already loaded" raise disnake.ClientException(msg) - self.remove_cog(cog_name) + await self.remove_cog(cog_name) # NOTE: Should be covariant - cog = cog._inject(self) # pyright: ignore[reportArgumentType] + cog = await cog._inject(self) # pyright: ignore[reportArgumentType] self.__cogs[cog_name] = cog def get_cog(self, name: str) -> Cog | None: @@ -227,7 +230,7 @@ def get_cog(self, name: str) -> Cog | None: """ return self.__cogs.get(name) - def remove_cog(self, name: str) -> Cog | None: + async def remove_cog(self, name: str) -> Cog | None: """Removes a cog from the bot and returns it. All registered commands and event listeners that the @@ -239,6 +242,9 @@ def remove_cog(self, name: str) -> Cog | None: :attr:`command_sync_flags.sync_on_cog_actions <.CommandSyncFlags.sync_on_cog_actions>` isn't disabled. + .. versionchanged:: 3.0 + This is now a coroutine. + Parameters ---------- name: :class:`str` @@ -257,7 +263,7 @@ def remove_cog(self, name: str) -> Cog | None: if help_command and help_command.cog is cog: help_command.cog = None # NOTE: Should be covariant - cog._eject(self) # pyright: ignore[reportArgumentType] + await cog._eject(self) # pyright: ignore[reportArgumentType] return cog @@ -268,12 +274,12 @@ def cogs(self) -> Mapping[str, Cog]: # extensions - def _remove_module_references(self, name: str) -> None: + async def _remove_module_references(self, name: str) -> None: # find all references to the module # remove the cogs registered from the module for cogname, cog in self.__cogs.copy().items(): if _is_submodule(name, cog.__module__): - self.remove_cog(cogname) + await self.remove_cog(cogname) # remove all the listeners from the module for event_list in self.extra_events.copy().values(): remove = [ @@ -285,14 +291,15 @@ def _remove_module_references(self, name: str) -> None: for index in reversed(remove): del event_list[index] - def _call_module_finalizers(self, lib: types.ModuleType, key: str) -> None: + async def _call_module_finalizers(self, lib: types.ModuleType, key: str) -> None: try: func = lib.teardown except AttributeError: pass else: try: - func(self) + # use partial to avoid accidental blocking + await disnake.utils.maybe_coroutine(partial(func, self)) except Exception as error: error.__suppress_context__ = True _log.error("Exception in extension finalizer %r", key, exc_info=error) @@ -304,7 +311,7 @@ def _call_module_finalizers(self, lib: types.ModuleType, key: str) -> None: if _is_submodule(name, module): del sys.modules[module] - def _load_from_module_spec(self, spec: importlib.machinery.ModuleSpec, key: str) -> None: + async def _load_from_module_spec(self, spec: importlib.machinery.ModuleSpec, key: str) -> None: # precondition: key not in self.__extensions lib = importlib.util.module_from_spec(spec) sys.modules[key] = lib @@ -322,11 +329,12 @@ def _load_from_module_spec(self, spec: importlib.machinery.ModuleSpec, key: str) raise errors.NoEntryPointError(key) from None try: - setup(self) + # use partial to avoid accidental blocking + await disnake.utils.maybe_coroutine(partial(setup, self)) except Exception as e: del sys.modules[key] - self._remove_module_references(lib.__name__) - self._call_module_finalizers(lib, key) + await self._remove_module_references(lib.__name__) + await self._call_module_finalizers(lib, key) raise errors.ExtensionFailed(key, e) from e else: self.__extensions[key] = lib @@ -337,7 +345,7 @@ def _resolve_name(self, name: str, package: str | None) -> str: except ImportError as e: raise errors.ExtensionNotFound(name) from e - def load_extension(self, name: str, *, package: str | None = None) -> None: + async def load_extension(self, name: str, *, package: str | None = None) -> None: """Loads an extension. An extension is a python module that contains commands, cogs, or @@ -347,6 +355,9 @@ def load_extension(self, name: str, *, package: str | None = None) -> None: the entry point on what to do when the extension is loaded. This entry point must have a single argument, the ``bot``. + .. versionchanged:: 3.0 + This is now a coroutine. + Parameters ---------- name: :class:`str` @@ -381,9 +392,9 @@ def load_extension(self, name: str, *, package: str | None = None) -> None: if spec is None: raise errors.ExtensionNotFound(name) - self._load_from_module_spec(spec, name) + await self._load_from_module_spec(spec, name) - def unload_extension(self, name: str, *, package: str | None = None) -> None: + async def unload_extension(self, name: str, *, package: str | None = None) -> None: """Unloads an extension. When the extension is unloaded, all commands, listeners, and cogs are @@ -394,6 +405,9 @@ def unload_extension(self, name: str, *, package: str | None = None) -> None: parameter, the ``bot``, similar to ``setup`` from :meth:`~.Bot.load_extension`. + .. versionchanged:: 3.0 + This is now a coroutine. + Parameters ---------- name: :class:`str` @@ -420,10 +434,10 @@ def unload_extension(self, name: str, *, package: str | None = None) -> None: if lib is None: raise errors.ExtensionNotLoaded(name) - self._remove_module_references(lib.__name__) - self._call_module_finalizers(lib, name) + await self._remove_module_references(lib.__name__) + await self._call_module_finalizers(lib, name) - def reload_extension(self, name: str, *, package: str | None = None) -> None: + async def reload_extension(self, name: str, *, package: str | None = None) -> None: """Atomically reloads an extension. This replaces the extension with the same extension, only refreshed. This is @@ -431,6 +445,9 @@ def reload_extension(self, name: str, *, package: str | None = None) -> None: except done in an atomic way. That is, if an operation fails mid-reload then the bot will roll-back to the prior working state. + .. versionchanged:: 3.0 + This is now a coroutine. + Parameters ---------- name: :class:`str` @@ -471,9 +488,9 @@ def reload_extension(self, name: str, *, package: str | None = None) -> None: try: # Unload and then load the module... - self._remove_module_references(lib.__name__) - self._call_module_finalizers(lib, name) - self.load_extension(name) + await self._remove_module_references(lib.__name__) + await self._call_module_finalizers(lib, name) + await self.load_extension(name) except Exception: # if the load failed, the remnants should have been # cleaned from the load_extension function call @@ -485,18 +502,21 @@ def reload_extension(self, name: str, *, package: str | None = None) -> None: sys.modules.update(modules) raise - def load_extensions(self, path: str) -> None: + async def load_extensions(self, path: str) -> None: """Loads all extensions in a directory. .. versionadded:: 2.4 + .. versionchanged:: 3.0 + This is now a coroutine. + Parameters ---------- path: :class:`str` The path to search for extensions """ for extension in disnake.utils.search_directory(path): - self.load_extension(extension) + await self.load_extension(extension) @property def extensions(self) -> Mapping[str, types.ModuleType]: @@ -539,7 +559,7 @@ async def _watchdog(self) -> None: for name in extensions: try: - self.reload_extension(name) + await self.reload_extension(name) except errors.ExtensionError as e: reload_log.exception(e) else: diff --git a/disnake/ext/commands/cooldowns.py b/disnake/ext/commands/cooldowns.py index 3eaafedb8d..7f40d7e4a1 100644 --- a/disnake/ext/commands/cooldowns.py +++ b/disnake/ext/commands/cooldowns.py @@ -298,11 +298,10 @@ class _Semaphore: overkill for what is basically a counter. """ - __slots__ = ("value", "loop", "_waiters") + __slots__ = ("value", "_waiters") def __init__(self, number: int) -> None: self.value: int = number - self.loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() self._waiters: deque[asyncio.Future] = deque() def __repr__(self) -> str: @@ -327,7 +326,7 @@ async def acquire(self, *, wait: bool = False) -> bool: return False while self.value <= 0: - future = self.loop.create_future() + future = asyncio.get_running_loop().create_future() self._waiters.append(future) try: await future diff --git a/disnake/ext/commands/interaction_bot_base.py b/disnake/ext/commands/interaction_bot_base.py index 87dc6131d1..254bbd671b 100644 --- a/disnake/ext/commands/interaction_bot_base.py +++ b/disnake/ext/commands/interaction_bot_base.py @@ -883,7 +883,7 @@ async def _sync_application_commands(self) -> None: msg = "This method is only usable in disnake.Client subclasses" raise NotImplementedError(msg) - if not self._command_sync_flags._sync_enabled or self._is_closed or self.loop.is_closed(): + if not self._command_sync_flags._sync_enabled or self._is_closed: return # We assume that all commands are already cached. @@ -989,7 +989,6 @@ async def _delayed_command_sync(self) -> None: or self._sync_queued.locked() or not self.is_ready() or self._is_closed - or self.loop.is_closed() ): return # We don't do this task on login or in parallel with a similar task @@ -1003,7 +1002,7 @@ def _schedule_app_command_preparation(self) -> None: msg = "Command sync is only possible in disnake.Client subclasses" raise NotImplementedError(msg) - self.loop.create_task( + asyncio.create_task( self._prepare_application_commands(), name="disnake: app_command_preparation" ) @@ -1012,7 +1011,7 @@ def _schedule_delayed_command_sync(self) -> None: msg = "This method is only usable in disnake.Client subclasses" raise NotImplementedError(msg) - self.loop.create_task(self._delayed_command_sync(), name="disnake: delayed_command_sync") + asyncio.create_task(self._delayed_command_sync(), name="disnake: delayed_command_sync") # Error handlers diff --git a/disnake/ext/tasks/__init__.py b/disnake/ext/tasks/__init__.py index 79ee8d2ddd..ce49afbe8b 100644 --- a/disnake/ext/tasks/__init__.py +++ b/disnake/ext/tasks/__init__.py @@ -42,18 +42,21 @@ class SleepHandle: - __slots__ = ("future", "loop", "handle") + __slots__ = ("future", "handle") - def __init__(self, dt: datetime.datetime, *, loop: asyncio.AbstractEventLoop) -> None: - self.loop = loop - self.future: asyncio.Future[bool] = loop.create_future() + def __init__(self, dt: datetime.datetime) -> None: + self.future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() relative_delta = disnake.utils.compute_timedelta(dt) - self.handle = loop.call_later(relative_delta, self.future.set_result, True) + self.handle = asyncio.get_running_loop().call_later( + relative_delta, self.future.set_result, True + ) def recalculate(self, dt: datetime.datetime) -> None: self.handle.cancel() relative_delta = disnake.utils.compute_timedelta(dt) - self.handle = self.loop.call_later(relative_delta, self.future.set_result, True) + self.handle = asyncio.get_running_loop().call_later( + relative_delta, self.future.set_result, True + ) def wait(self) -> asyncio.Future[bool]: return self.future @@ -82,14 +85,12 @@ def __init__( time: datetime.time | Sequence[datetime.time] = MISSING, count: int | None = None, reconnect: bool = True, - loop: asyncio.AbstractEventLoop = MISSING, ) -> None: """.. note: If you overwrite ``__init__`` arguments, make sure to redefine .clone too. """ self.coro: LF = coro self.reconnect: bool = reconnect - self.loop: asyncio.AbstractEventLoop = loop self.count: int | None = count self._current_loop = 0 self._handle: SleepHandle = MISSING @@ -133,7 +134,7 @@ async def _call_loop_function(self, name: str, *args: Any, **kwargs: Any) -> Non await coro(*args, **kwargs) def _try_sleep_until(self, dt: datetime.datetime) -> asyncio.Future[bool]: - self._handle = SleepHandle(dt=dt, loop=self.loop) + self._handle = SleepHandle(dt=dt) return self._handle.wait() async def _loop(self, *args: Any, **kwargs: Any) -> None: @@ -208,7 +209,6 @@ def clone(self) -> Self: time=self._time, count=self.count, reconnect=self.reconnect, - loop=self.loop, ) instance._before_loop = self._before_loop instance._after_loop = self._after_loop @@ -321,10 +321,8 @@ def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]: if self._injected is not None: args = (self._injected, *args) - if self.loop is MISSING: - self.loop = disnake.utils.get_event_loop() + self._task = asyncio.create_task(self._loop(*args, **kwargs)) - self._task = self.loop.create_task(self._loop(*args, **kwargs)) return self._task def stop(self) -> None: @@ -714,7 +712,6 @@ def loop( time: datetime.time | Sequence[datetime.time] = ..., count: int | None = None, reconnect: bool = True, - loop: asyncio.AbstractEventLoop = ..., ) -> Callable[[LF], Loop[LF]]: ... @@ -731,6 +728,9 @@ def loop( r"""A decorator that schedules a task in the background for you with optional reconnect logic. The decorator returns a :class:`Loop`. + .. versionchanged:: 3.0 + The ``loop`` parameter has been removed. Change the loop using :func:`asyncio.set_event_loop`. + Parameters ---------- cls: :class:`~collections.abc.Callable`\[..., :class:`Loop`] @@ -764,9 +764,6 @@ def loop( reconnect: :class:`bool` Whether to handle errors and restart the task using an exponential back-off algorithm similar to the one used in :meth:`disnake.Client.connect`. - loop: :class:`asyncio.AbstractEventLoop` - The loop to use to register the task, if not given defaults to the current event loop - or creates a new one if there is none. Raises ------ diff --git a/disnake/gateway.py b/disnake/gateway.py index cd6ce453ea..abff9a6f36 100644 --- a/disnake/gateway.py +++ b/disnake/gateway.py @@ -165,6 +165,12 @@ def __init__( *args: Any, ws: HeartbeatWebSocket, interval: float, + # this loop sharing is necessary because KeepAliveHandler calls HeartbeatWebSocket's + # async methods and you can't run tasks made using one loop in another + # ("task attached to a different loop"), so the KeepAliveHandler's thread has to + # have access to main (this) thread's asyncio loop, and the only way for it to + # access said loop is to directly pass it as an object + loop: asyncio.AbstractEventLoop, shard_id: int | None = None, **kwargs: Any, ) -> None: @@ -172,6 +178,7 @@ def __init__( self.ws: HeartbeatWebSocket = ws self._main_thread_id: int = ws.thread_id self.interval: float = interval + self.loop = loop self.daemon: bool = True self.shard_id: int | None = shard_id self.msg = "Keeping shard ID %s websocket alive with sequence %s." @@ -192,7 +199,7 @@ def run(self) -> None: self.shard_id, ) coro = self.ws.close(4000) - f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) + f = asyncio.run_coroutine_threadsafe(coro, loop=self.loop) try: f.result() @@ -208,7 +215,7 @@ def run(self) -> None: data = self.get_payload() _log.debug(self.msg, self.shard_id, data["d"]) coro = self.ws.send_heartbeat(data) - f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) + f = asyncio.run_coroutine_threadsafe(coro, loop=self.loop) try: # block until sending is complete total = 0 @@ -276,7 +283,6 @@ class HeartbeatWebSocket(Protocol): HEARTBEAT: Final[Literal[1, 3]] = 1 thread_id: int - loop: asyncio.AbstractEventLoop _max_heartbeat_timeout: float async def close(self, code: int) -> None: ... @@ -341,10 +347,10 @@ class DiscordWebSocket: GUILD_SYNC: Final[Literal[12]] = 12 def __init__( - self, socket: aiohttp.ClientWebSocketResponse, *, loop: asyncio.AbstractEventLoop + self, + socket: aiohttp.ClientWebSocketResponse, ) -> None: self.socket: aiohttp.ClientWebSocketResponse = socket - self.loop: asyncio.AbstractEventLoop = loop # an empty dispatcher to prevent crashes self._dispatch: DispatchFunc = lambda event, *args: None @@ -416,7 +422,7 @@ async def from_client( gateway = await client.http.get_gateway(encoding=params.encoding, zlib=params.zlib) socket = await client.http.ws_connect(gateway) - ws = cls(socket, loop=client.loop) + ws = cls(socket) # dynamically add attributes needed ws.token = client.http.token # pyright: ignore[reportAttributeAccessIssue] @@ -479,7 +485,7 @@ def wait_for( asyncio.Future A future to wait for. """ - future = self.loop.create_future() + future = asyncio.get_running_loop().create_future() entry = EventListener(event=event, predicate=predicate, result=result, future=future) self._dispatch_listeners.append(entry) return future @@ -583,8 +589,12 @@ async def received_message(self, raw_msg: str | bytes, /) -> None: if op == self.HELLO: interval: float = data["heartbeat_interval"] / 1000.0 self._keep_alive = KeepAliveHandler( - ws=self, interval=interval, shard_id=self.shard_id + ws=self, + interval=interval, + shard_id=self.shard_id, + loop=asyncio.get_running_loop(), ) + self._keep_alive.name = "disnake heartbeat thread" # send a heartbeat immediately await self.send_as_json(self._keep_alive.get_payload()) self._keep_alive.start() @@ -900,13 +910,10 @@ class DiscordVoiceWebSocket: def __init__( self, socket: aiohttp.ClientWebSocketResponse, - loop: asyncio.AbstractEventLoop, *, hook: HookFunc | None = None, ) -> None: self.ws: aiohttp.ClientWebSocketResponse = socket - self.loop: asyncio.AbstractEventLoop = loop - self._keep_alive: VoiceKeepAliveHandler | None = None self.sequence: int = -1 @@ -974,7 +981,7 @@ async def from_client( gateway = f"wss://{client.endpoint}/?v={_VOICE_VERSION}" http = client._state.http socket = await http.ws_connect(gateway, compress=15) - ws = cls(socket, loop=client.loop, hook=hook) + ws = cls(socket, hook=hook) ws.gateway = gateway ws._connection = client ws._max_heartbeat_timeout = 60.0 @@ -1038,7 +1045,9 @@ async def received_message(self, msg: VoicePayload) -> None: self._ready.set() elif op == self.HELLO: interval: float = data["heartbeat_interval"] / 1000.0 - self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0)) + self._keep_alive = VoiceKeepAliveHandler( + ws=self, interval=min(interval, 5.0), loop=asyncio.get_running_loop() + ) self._keep_alive.start() await self._hook(self, msg) @@ -1054,7 +1063,7 @@ async def initial_connection(self, data: VoiceReadyPayload) -> None: struct.pack_into(">H", packet, 2, 70) # 70 = Length struct.pack_into(">I", packet, 4, state.ssrc) state.socket.sendto(packet, (state.endpoint_ip, state.voice_port)) - recv = await self.loop.sock_recv(state.socket, 74) + recv = await asyncio.get_running_loop().sock_recv(state.socket, 74) _log.debug("received packet in initial_connection: %s", recv) # the ip is ascii starting at the 8th byte and ending at the first null diff --git a/disnake/http.py b/disnake/http.py index 2f713c8a94..063260aba4 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -224,12 +224,10 @@ def __init__( self, connector: aiohttp.BaseConnector | None = None, *, - loop: asyncio.AbstractEventLoop, proxy: str | None = None, proxy_auth: aiohttp.BasicAuth | None = None, unsync_clock: bool = True, ) -> None: - self.loop: asyncio.AbstractEventLoop = loop self.connector = connector self.__session: aiohttp.ClientSession = MISSING # filled in static_login self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary() @@ -366,7 +364,7 @@ async def request( delta, ) maybe_lock.defer() - self.loop.call_later(delta, lock.release) + asyncio.get_running_loop().call_later(delta, lock.release) # the request was successful so just return the text/json if 300 > response.status >= 200: diff --git a/disnake/player.py b/disnake/player.py index da6ddf85b8..061eed3b4c 100644 --- a/disnake/player.py +++ b/disnake/player.py @@ -700,11 +700,20 @@ def read(self) -> bytes: class AudioPlayer(threading.Thread): DELAY: float = OpusEncoder.FRAME_LENGTH / 1000.0 - def __init__(self, source: AudioSource, client: VoiceClient, *, after=None) -> None: + def __init__( + self, + source: AudioSource, + client: VoiceClient, + # see KeepAliveHandler's reasoning for sharing the loop + loop: asyncio.AbstractEventLoop, + *, + after=None, + ) -> None: threading.Thread.__init__(self) self.daemon: bool = True self.source: AudioSource = source self.client: VoiceClient = client + self.loop = loop self.after: Callable[[Exception | None], Any] | None = after self._end: threading.Event = threading.Event() @@ -815,6 +824,6 @@ def _set_source(self, source: AudioSource) -> None: def _speak(self, speaking: bool) -> None: try: - asyncio.run_coroutine_threadsafe(self.client.ws.speak(speaking), self.client.loop) + asyncio.run_coroutine_threadsafe(self.client.ws.speak(speaking), self.loop) except Exception as e: _log.info("Speaking call in player failed: %s", e) diff --git a/disnake/shard.py b/disnake/shard.py index f0800bb125..9133cb6e68 100644 --- a/disnake/shard.py +++ b/disnake/shard.py @@ -90,7 +90,6 @@ def __init__( self._client: Client = client self._dispatch: Callable[..., None] = client.dispatch self._queue_put: Callable[[EventItem], None] = queue_put - self.loop: asyncio.AbstractEventLoop = self._client.loop self._disconnect: bool = False self._reconnect = client._reconnect self._backoff: ExponentialBackoff[Literal[False]] = ExponentialBackoff() @@ -111,7 +110,7 @@ def id(self) -> int: return self.ws.shard_id def launch(self) -> None: - self._task = self.loop.create_task(self.worker()) + self._task = asyncio.create_task(self.worker()) def _cancel_task(self) -> None: if self._task is not None and not self._task.done(): @@ -345,7 +344,6 @@ def __init__( self, *, asyncio_debug: bool = False, - loop: asyncio.AbstractEventLoop | None = None, shard_ids: list[int] | None = None, # instead of Client's shard_id: int | None shard_count: int | None = None, enable_debug_events: bool = False, @@ -407,7 +405,6 @@ def _get_state(self, **options: Any) -> AutoShardedConnectionState: handlers=self._handlers, hooks=self._hooks, http=self.http, - loop=self.loop, **options, ) @@ -536,7 +533,8 @@ async def close(self) -> None: pass to_close = [ - asyncio.ensure_future(shard.close(), loop=self.loop) for shard in self.__shards.values() + asyncio.ensure_future(shard.close(), loop=asyncio.get_running_loop()) + for shard in self.__shards.values() ] if to_close: await asyncio.wait(to_close) diff --git a/disnake/state.py b/disnake/state.py index c295b0dca3..332deaecb0 100644 --- a/disnake/state.py +++ b/disnake/state.py @@ -123,13 +123,12 @@ class AsyncRequest(Generic[T]): - def __init__(self, guild_id: int, loop: asyncio.AbstractEventLoop) -> None: + def __init__(self, guild_id: int) -> None: self.guild_id: int = guild_id - self.loop: asyncio.AbstractEventLoop = loop self.waiters: list[asyncio.Future[T]] = [] async def wait(self) -> T: - future: asyncio.Future[T] = self.loop.create_future() + future: asyncio.Future[T] = asyncio.get_running_loop().create_future() self.waiters.append(future) try: return await future @@ -137,7 +136,7 @@ async def wait(self) -> T: self.waiters.remove(future) def get_future(self) -> asyncio.Future[T]: - future: asyncio.Future[T] = self.loop.create_future() + future: asyncio.Future[T] = asyncio.get_running_loop().create_future() self.waiters.append(future) return future @@ -151,12 +150,11 @@ class ChunkRequest(AsyncRequest[list[Member]]): def __init__( self, guild_id: int, - loop: asyncio.AbstractEventLoop, resolver: Callable[[int], Any], *, cache: bool = True, ) -> None: - super().__init__(guild_id=guild_id, loop=loop) + super().__init__(guild_id=guild_id) self.resolver: Callable[[int], Any] = resolver self.cache: bool = cache self.nonce: str = os.urandom(16).hex() @@ -201,7 +199,6 @@ def __init__( handlers: dict[str, Callable[..., Any]], hooks: dict[str, Callable[..., Any]], http: HTTPClient, - loop: asyncio.AbstractEventLoop, max_messages: int | None = 1000, application_id: int | None = None, heartbeat_timeout: float = 60.0, @@ -213,7 +210,6 @@ def __init__( chunk_guilds_at_startup: bool | None = None, member_cache_flags: MemberCacheFlags | None = None, ) -> None: - self.loop: asyncio.AbstractEventLoop = loop self.http: HTTPClient = http self.max_messages: int | None = max_messages if self.max_messages is not None and self.max_messages <= 0: @@ -681,7 +677,7 @@ async def query_members( guild_id = guild.id ws = self._get_websocket(guild_id) - request = ChunkRequest(guild.id, self.loop, self._get_guild, cache=cache) + request = ChunkRequest(guild.id, self._get_guild, cache=cache) self._chunk_requests[request.nonce] = request try: @@ -1465,7 +1461,7 @@ async def chunk_guild( request = self._chunk_requests.get(guild.id) if request is None: self._chunk_requests[guild.id] = request = ChunkRequest( - guild.id, self.loop, self._get_guild, cache=cache + guild.id, self._get_guild, cache=cache ) await self.chunker(guild.id, nonce=request.nonce) @@ -2515,7 +2511,7 @@ async def _delay_ready(self) -> None: future = asyncio.ensure_future(self.chunk_guild(guild)) current_bucket.append(future) else: - future = self.loop.create_future() + future = asyncio.get_running_loop().create_future() future.set_result([]) processed.append((guild, future)) diff --git a/disnake/ui/view.py b/disnake/ui/view.py index a1573a992b..82fb6e3e5c 100644 --- a/disnake/ui/view.py +++ b/disnake/ui/view.py @@ -144,12 +144,11 @@ def __init__(self, *, timeout: float | None = 180.0) -> None: self.children.append(item) self.__weights = _ViewWeights(self.children) - loop = asyncio.get_running_loop() self.id: str = os.urandom(16).hex() self.__cancel_callback: Callable[[View], None] | None = None self.__timeout_expiry: float | None = None self.__timeout_task: asyncio.Task[None] | None = None - self.__stopped: asyncio.Future[bool] = loop.create_future() + self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() def __repr__(self) -> str: return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" @@ -373,12 +372,11 @@ async def _scheduled_task(self, item: Item, interaction: MessageInteraction) -> def _start_listening_from_store(self, store: ViewStore) -> None: self.__cancel_callback = partial(store.remove_view) if self.timeout: - loop = asyncio.get_running_loop() if self.__timeout_task is not None: self.__timeout_task.cancel() self.__timeout_expiry = time.monotonic() + self.timeout - self.__timeout_task = loop.create_task(self.__timeout_task_impl()) + self.__timeout_task = asyncio.create_task(self.__timeout_task_impl()) def _dispatch_timeout(self) -> None: if self.__stopped.done(): diff --git a/disnake/utils.py b/disnake/utils.py index c6b9df08ba..ae851238b6 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -56,29 +56,10 @@ from .enums import Locale if sys.version_info >= (3, 14): - import threading from inspect import iscoroutinefunction as iscoroutinefunction - - def get_event_loop(): - try: - # If there is no event loop, this will raise a RuntimeError starting with Python 3.14+. - # In that case, we create and set a new loop below. - # This is more of a bandaid fix, we should really use asyncio.run in the long term. - return asyncio.get_event_loop() - except RuntimeError: - if threading.current_thread() is not threading.main_thread(): - raise - asyncio.set_event_loop(loop := asyncio.new_event_loop()) - return loop else: from asyncio import iscoroutinefunction as iscoroutinefunction - def get_event_loop(): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - # get_event_loop emits deprecation warnings in 3.10-3.13 - return asyncio.get_event_loop() - try: import orjson diff --git a/disnake/voice_client.py b/disnake/voice_client.py index 6dacec87a1..d53099f9b2 100644 --- a/disnake/voice_client.py +++ b/disnake/voice_client.py @@ -172,6 +172,9 @@ class VoiceClient(VoiceProtocol): You do not create these, you typically get them from e.g. :meth:`VoiceChannel.connect`. + .. versionchanged:: 3.0 + ``VoiceClient.loop`` has been removed. + Warning ------- In order to use PCM based AudioSources, you must have the opus library @@ -189,8 +192,6 @@ class VoiceClient(VoiceProtocol): The endpoint we are connecting to. channel: :class:`abc.Connectable` The voice channel connected to. - loop: :class:`asyncio.AbstractEventLoop` - The event loop that the voice client is running on. """ endpoint_ip: str @@ -209,7 +210,6 @@ def __init__(self, client: Client, channel: abc.Connectable) -> None: state = client._connection self.token: str = MISSING self.socket: socket.socket = MISSING - self.loop: asyncio.AbstractEventLoop = state.loop self._state: ConnectionState = state # this will be used in the AudioPlayer thread self._connected: threading.Event = threading.Event() @@ -375,7 +375,7 @@ async def connect(self, *, reconnect: bool, timeout: float) -> None: raise if self._runner is MISSING: - self._runner = self.loop.create_task(self.poll_voice_ws(reconnect)) + self._runner = asyncio.create_task(self.poll_voice_ws(reconnect)) async def potential_reconnect(self) -> bool: # Attempt to stop the player thread from playing early @@ -605,7 +605,7 @@ def play( if not self.encoder and not source.is_opus(): self.encoder = opus.Encoder() - self._player = AudioPlayer(source, self, after=after) + self._player = AudioPlayer(source, self, asyncio.get_running_loop(), after=after) self._player.start() def is_playing(self) -> bool: diff --git a/docs/ext/commands/cogs.rst b/docs/ext/commands/cogs.rst index 5a5f5389f0..024799704c 100644 --- a/docs/ext/commands/cogs.rst +++ b/docs/ext/commands/cogs.rst @@ -63,7 +63,7 @@ Once you have defined your cogs, you need to tell the bot to register the cogs t .. code-block:: python3 - bot.add_cog(Greetings(bot)) + await bot.add_cog(Greetings(bot)) This binds the cog to the bot, adding all commands and listeners to the bot automatically. @@ -71,7 +71,7 @@ Note that we reference the cog by name, which we can override through :ref:`ext_ .. code-block:: python3 - bot.remove_cog('Greetings') + await bot.remove_cog('Greetings') Using Cogs ---------- diff --git a/docs/ext/commands/extensions.rst b/docs/ext/commands/extensions.rst index d3db0436c5..d7699124ec 100644 --- a/docs/ext/commands/extensions.rst +++ b/docs/ext/commands/extensions.rst @@ -36,6 +36,11 @@ In this example we define a simple command, and when the extension is loaded thi Extensions are usually used in conjunction with cogs. To read more about them, check out the documentation, :ref:`ext_commands_cogs`. +.. admonition:: Async + :class: helpful + + ``setup`` (as well as ``teardown``, see below) can be ``async`` too! + .. note:: Extension paths are ultimately similar to the import mechanism. What this means is that if there is a folder, then it must be dot-qualified. For example to load an extension in ``plugins/hello.py`` then we use the string ``plugins.hello``. @@ -47,7 +52,7 @@ When you make a change to the extension and want to reload the references, the l .. code-block:: python3 - >>> bot.reload_extension('hello') + >>> await bot.reload_extension('hello') Once the extension reloads, any changes that we did will be applied. This is useful if we want to add or remove functionality without restarting our bot. If an error occurred during the reloading process, the bot will pretend as if the reload never happened. diff --git a/docs/ext/tasks/index.rst b/docs/ext/tasks/index.rst index b94611437e..1f8a1cb195 100644 --- a/docs/ext/tasks/index.rst +++ b/docs/ext/tasks/index.rst @@ -153,5 +153,5 @@ API Reference .. automethod:: Loop.error() :decorator: -.. autofunction:: disnake.ext.tasks.loop(cls=Loop, *, seconds=..., minutes=..., hours=..., time=..., count=None, reconnect=True, loop=...) +.. autofunction:: disnake.ext.tasks.loop(cls=Loop, *, seconds=..., minutes=..., hours=..., time=..., count=None, reconnect=True) :decorator: diff --git a/examples/basic_voice.py b/examples/basic_voice.py index adb19d44c7..69a0c6548d 100644 --- a/examples/basic_voice.py +++ b/examples/basic_voice.py @@ -43,11 +43,8 @@ def __init__(self, source: disnake.AudioSource, *, data: dict[str, Any], volume: self.title = data.get("title") @classmethod - async def from_url( - cls, url, *, loop: asyncio.AbstractEventLoop | None = None, stream: bool = False - ): - loop = loop or asyncio.get_event_loop() - data: Any = await loop.run_in_executor( + async def from_url(cls, url, *, stream: bool = False): + data: Any = await asyncio.get_running_loop().run_in_executor( None, lambda: ytdl.extract_info(url, download=not stream) ) @@ -95,7 +92,7 @@ async def stream(self, ctx, *, url: str): async def _play_url(self, ctx, *, url: str, stream: bool): await self.ensure_voice(ctx) async with ctx.typing(): - player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=stream) + player = await YTDLSource.from_url(url, stream=stream) ctx.voice_client.play( player, after=lambda e: print(f"Player error: {e}") if e else None ) @@ -139,7 +136,11 @@ async def on_ready(): print(f"Logged in as {bot.user} (ID: {bot.user.id})\n------") -bot.add_cog(Music(bot)) +async def setup_hook(): + await bot.add_cog(Music(bot)) + + +bot.setup_hook = setup_hook if __name__ == "__main__": bot.run(os.getenv("BOT_TOKEN")) diff --git a/examples/interactions/subcmd.py b/examples/interactions/subcmd.py index ef9da28c38..119dffa6fd 100644 --- a/examples/interactions/subcmd.py +++ b/examples/interactions/subcmd.py @@ -71,7 +71,11 @@ async def on_ready(): print(f"Logged in as {bot.user} (ID: {bot.user.id})\n------") -bot.add_cog(MyCog()) +async def setup_hook(): + await bot.add_cog(MyCog()) + + +bot.setup_hook = setup_hook if __name__ == "__main__": bot.run(os.getenv("BOT_TOKEN")) diff --git a/pyproject.toml b/pyproject.toml index 5ebc65fa3a..5e4397ed7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,9 +77,7 @@ tools = [ "check-wheel-contents~=0.6.3", { include-group = "ruff" }, ] -changelog = [ - "towncrier==23.6.0", -] +changelog = ["towncrier==23.6.0"] codemod = [ # run codemods on the repository (mostly automated typing) "libcst==1.8.5", @@ -334,35 +332,35 @@ title_format = false underlines = "-~" issue_format = ":issue:`{issue}`" - [[tool.towncrier.type]] - directory = "breaking" - name = "Breaking Changes" - showcontent = true +[[tool.towncrier.type]] +directory = "breaking" +name = "Breaking Changes" +showcontent = true - [[tool.towncrier.type]] - directory = "deprecate" - name = "Deprecations" - showcontent = true +[[tool.towncrier.type]] +directory = "deprecate" +name = "Deprecations" +showcontent = true - [[tool.towncrier.type]] - directory = "feature" - name = "New Features" - showcontent = true +[[tool.towncrier.type]] +directory = "feature" +name = "New Features" +showcontent = true - [[tool.towncrier.type]] - directory = "bugfix" - name = "Bug Fixes" - showcontent = true +[[tool.towncrier.type]] +directory = "bugfix" +name = "Bug Fixes" +showcontent = true - [[tool.towncrier.type]] - directory = "doc" - name = "Documentation" - showcontent = true +[[tool.towncrier.type]] +directory = "doc" +name = "Documentation" +showcontent = true - [[tool.towncrier.type]] - directory = "misc" - name = "Miscellaneous" - showcontent = true +[[tool.towncrier.type]] +directory = "misc" +name = "Miscellaneous" +showcontent = true [tool.slotscheck] @@ -433,15 +431,8 @@ asyncio_mode = "strict" [tool.coverage.run] branch = true -include = [ - "disnake/*", - "tests/*", -] -omit = [ - "disnake/ext/mypy_plugin/*", - "disnake/types/*", - "disnake/__main__.py", -] +include = ["disnake/*", "tests/*"] +omit = ["disnake/ext/mypy_plugin/*", "disnake/types/*", "disnake/__main__.py"] [tool.coverage.report] precision = 1 diff --git a/tests/ext/tasks/test_loops.py b/tests/ext/tasks/test_loops.py index 4a7244b70e..efd72a5271 100644 --- a/tests/ext/tasks/test_loops.py +++ b/tests/ext/tasks/test_loops.py @@ -46,7 +46,6 @@ def clone(self): instance._time = self._time instance.count = self.count instance.reconnect = self.reconnect - instance.loop = self.loop instance._before_loop = self._before_loop instance._after_loop = self._after_loop instance._error = self._error diff --git a/tests/test_events.py b/tests/test_events.py index 0eec869b31..2dccc30186 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -42,8 +42,9 @@ async def on_message_edit(self, *args: Any) -> None: ... # Client.wait_for +@pytest.mark.asyncio @pytest.mark.parametrize("event", ["thread_create", Event.thread_create]) -def test_wait_for(client_or_bot: disnake.Client, event) -> None: +async 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 @@ -90,20 +91,22 @@ async def on_guild_role_create(self, *args: Any) -> None: ... # @commands.Cog.listener +@pytest.mark.asyncio @pytest.mark.parametrize("event", ["on_automod_rule_update", Event.automod_rule_update]) -def test_listener(bot: commands.Bot, event) -> None: +async def test_listener(bot: commands.Bot, event) -> None: class Cog(commands.Cog): @commands.Cog.listener(event) async def callback(self, *args: Any) -> None: ... - bot.add_cog(Cog()) + await bot.add_cog(Cog()) assert len(bot.extra_events["on_automod_rule_update"]) == 1 -def test_listener__implicit(bot: commands.Bot) -> None: +@pytest.mark.asyncio +async def test_listener__implicit(bot: commands.Bot) -> None: class Cog(commands.Cog): @commands.Cog.listener() async def on_automod_rule_update(self, *args: Any) -> None: ... - bot.add_cog(Cog()) + await bot.add_cog(Cog()) assert len(bot.extra_events["on_automod_rule_update"]) == 1