From de49cf507944bff25fa3cf53b6b0e4c2067a8128 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Nov 2024 18:25:23 +0100 Subject: [PATCH 01/37] fix: client.run not allowing bots to start --- discord/client.py | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/discord/client.py b/discord/client.py index cbc22813fd..71e8b860b4 100644 --- a/discord/client.py +++ b/discord/client.py @@ -778,13 +778,6 @@ def run(self, *args: Any, **kwargs: Any) -> None: is blocking. That means that registration of events or anything being called after this function call will not execute until it returns. """ - loop = self.loop - - try: - loop.add_signal_handler(signal.SIGINT, loop.stop) - loop.add_signal_handler(signal.SIGTERM, loop.stop) - except (NotImplementedError, RuntimeError): - pass async def runner(): try: @@ -793,26 +786,10 @@ async def runner(): if not self.is_closed(): await self.close() - def stop_loop_on_completion(f): - loop.stop() - - future = asyncio.ensure_future(runner(), loop=loop) - future.add_done_callback(stop_loop_on_completion) try: - loop.run_forever() + asyncio.run(runner()) 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: - return future.result() - except KeyboardInterrupt: - # I am unsure why this gets raised here but suppress it anyway - return None + return # properties From 4fe559fe47373b3186be4b25ec0cd9d7fdaeef52 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:35:51 +0100 Subject: [PATCH 02/37] chore: Update more things --- discord/client.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/discord/client.py b/discord/client.py index 71e8b860b4..f21303bfb0 100644 --- a/discord/client.py +++ b/discord/client.py @@ -27,7 +27,6 @@ import asyncio import logging -import signal import sys import traceback from types import TracebackType @@ -221,14 +220,12 @@ class Client: def __init__( self, *, - loop: asyncio.AbstractEventLoop | None = None, + loop: asyncio.AbstractEventLoop = MISSING, **options: Any, ): # self.ws is set in the connect method self.ws: DiscordWebSocket = None # type: ignore - self.loop: asyncio.AbstractEventLoop = ( - asyncio.get_event_loop() if loop is None else loop - ) + self.loop: asyncio.AbstractEventLoop = loop self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( {} ) @@ -780,16 +777,26 @@ def run(self, *args: Any, **kwargs: Any) -> None: """ async def runner(): + # Update the bot loop to replace MISSING + self.loop = asyncio.get_event_loop() try: await self.start(*args, **kwargs) finally: if not self.is_closed(): await self.close() + run = asyncio.run + + if self.loop is not MISSING: + run = self.loop.run_until_complete + try: - asyncio.run(runner()) - except KeyboardInterrupt: - return + run(runner()) + finally: + if not self.is_closed(): + self.loop.run_until_complete(self.close()) + + _cleanup_loop(self.loop) # properties From 74863c5424fa6d296bd5a3ae7f73f83faa443f9d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:36:35 +0100 Subject: [PATCH 03/37] chore: Move the loop update to .start --- discord/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/discord/client.py b/discord/client.py index f21303bfb0..a1556d1358 100644 --- a/discord/client.py +++ b/discord/client.py @@ -748,6 +748,9 @@ async def start(self, token: str, *, reconnect: bool = True) -> None: TypeError An unexpected keyword argument was received. """ + # Update the loop to get the running one in case the one set is MISSING + if self.loop is MISSING: + self.loop = asyncio.get_event_loop() await self.login(token) await self.connect(reconnect=reconnect) @@ -777,8 +780,6 @@ def run(self, *args: Any, **kwargs: Any) -> None: """ async def runner(): - # Update the bot loop to replace MISSING - self.loop = asyncio.get_event_loop() try: await self.start(*args, **kwargs) finally: From 72f904c486d27340bdff7b3733c2f90f6d92802c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Nov 2024 21:31:13 +0100 Subject: [PATCH 04/37] chore: Added logging and updated `run` to specify the arguments --- discord/client.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/discord/client.py b/discord/client.py index a1556d1358..74267bcabe 100644 --- a/discord/client.py +++ b/discord/client.py @@ -751,10 +751,13 @@ async def start(self, token: str, *, reconnect: bool = True) -> None: # Update the loop to get the running one in case the one set is MISSING if self.loop is MISSING: self.loop = asyncio.get_event_loop() + self.http.loop = self.loop + self._connection.loop = self.loop + await self.login(token) await self.connect(reconnect=reconnect) - def run(self, *args: Any, **kwargs: Any) -> None: + def run(self, token: str, *, reconnect: bool = True) -> None: """A blocking call that abstracts away the event loop initialisation from you. @@ -765,12 +768,17 @@ def run(self, *args: Any, **kwargs: Any) -> None: Roughly Equivalent to: :: try: - loop.run_until_complete(start(*args, **kwargs)) + asyncio.run(start(token)) except KeyboardInterrupt: - loop.run_until_complete(close()) - # cancel all tasks lingering - finally: - loop.close() + return + + Parameters + ---------- + token: :class:`str` + The authentication token. Do not prefix this token with anything as the library will do it for you. + reconnect: :class:`bool` + If we should attempt reconnecting to the gateway, either due to internet failure or a specific failure on Discord's part. + Certain disconnects that lead to bad state will not be handled (such as invalid sharding payloads or bad tokens). .. warning:: @@ -781,7 +789,7 @@ def run(self, *args: Any, **kwargs: Any) -> None: async def runner(): try: - await self.start(*args, **kwargs) + await self.start(token, reconnect=reconnect) finally: if not self.is_closed(): await self.close() @@ -794,9 +802,11 @@ async def runner(): try: run(runner()) finally: + # Ensure the bot is closed if not self.is_closed(): self.loop.run_until_complete(self.close()) + _log.info('Cleaning up tasks.') _cleanup_loop(self.loop) # properties From 7e7f1e959c0be329cc3f9bf85ab875a82fb6a6f3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:31:44 +0000 Subject: [PATCH 05/37] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 74267bcabe..d023eac7dd 100644 --- a/discord/client.py +++ b/discord/client.py @@ -806,7 +806,7 @@ async def runner(): if not self.is_closed(): self.loop.run_until_complete(self.close()) - _log.info('Cleaning up tasks.') + _log.info("Cleaning up tasks.") _cleanup_loop(self.loop) # properties From f289f74a2880e23af40fe31f96a09459f57ffea2 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Nov 2024 21:32:46 +0100 Subject: [PATCH 06/37] chore: Updated CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc995ffd76..b6b0727815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ These changes are available on the `master` branch, but have not yet been releas ([#2627](https://github.com/Pycord-Development/pycord/issues/2627)) - Fixed `AttributeError` when sending polls with `PartialWebook`. ([#2624](https://github.com/Pycord-Development/pycord/pull/2624)) +- Fixed Async I/O errors that could be raised when using `Client.run` ([#2645](https://github.com/Pycord-Development/pycord/pull/2645)) ### Changed From 3d235eebb0b3014b90575e643aa4e6c49b3b0598 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:36:27 +0000 Subject: [PATCH 07/37] style(pre-commit): auto fixes from pre-commit.com hooks --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b0727815..3ca1a883ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#2627](https://github.com/Pycord-Development/pycord/issues/2627)) - Fixed `AttributeError` when sending polls with `PartialWebook`. ([#2624](https://github.com/Pycord-Development/pycord/pull/2624)) -- Fixed Async I/O errors that could be raised when using `Client.run` ([#2645](https://github.com/Pycord-Development/pycord/pull/2645)) +- Fixed Async I/O errors that could be raised when using `Client.run` + ([#2645](https://github.com/Pycord-Development/pycord/pull/2645)) ### Changed From 07ccbc97f14c16603fee2a117e2bad28c1f0778c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 11 Nov 2024 21:37:05 +0100 Subject: [PATCH 08/37] dot --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca1a883ab..a34bee9eb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,7 @@ These changes are available on the `master` branch, but have not yet been releas ([#2627](https://github.com/Pycord-Development/pycord/issues/2627)) - Fixed `AttributeError` when sending polls with `PartialWebook`. ([#2624](https://github.com/Pycord-Development/pycord/pull/2624)) -- Fixed Async I/O errors that could be raised when using `Client.run` +- Fixed Async I/O errors that could be raised when using `Client.run`. ([#2645](https://github.com/Pycord-Development/pycord/pull/2645)) ### Changed From 5e8a322ab9335e152430b623d1dec60d82f3c153 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 19 Nov 2024 08:23:39 +0100 Subject: [PATCH 09/37] Update docstrings Co-authored-by: Dorukyum <53639936+Dorukyum@users.noreply.github.com> Signed-off-by: DA344 <108473820+DA-344@users.noreply.github.com> --- discord/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/client.py b/discord/client.py index d023eac7dd..4742bce52c 100644 --- a/discord/client.py +++ b/discord/client.py @@ -777,8 +777,10 @@ def run(self, token: str, *, reconnect: bool = True) -> None: token: :class:`str` The authentication token. Do not prefix this token with anything as the library will do it for you. reconnect: :class:`bool` - If we should attempt reconnecting to the gateway, either due to internet failure or a specific failure on Discord's part. - Certain disconnects that lead to bad state will not be handled (such as invalid sharding payloads or bad tokens). + If we should attempt reconnecting to the gateway, either due to internet + failure or a specific failure on Discord's part. Certain + disconnects that lead to bad state will not be handled (such as + invalid sharding payloads or bad tokens). .. warning:: From de5156c2ae8eb7d687b09d7f75f65c6054230473 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 19 Nov 2024 08:23:53 +0100 Subject: [PATCH 10/37] Update docstrings Co-authored-by: Dorukyum <53639936+Dorukyum@users.noreply.github.com> Signed-off-by: DA344 <108473820+DA-344@users.noreply.github.com> --- discord/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 4742bce52c..137c1e1de2 100644 --- a/discord/client.py +++ b/discord/client.py @@ -775,7 +775,8 @@ def run(self, token: str, *, reconnect: bool = True) -> None: Parameters ---------- token: :class:`str` - The authentication token. Do not prefix this token with anything as the library will do it for you. + The authentication token. Do not prefix this token with + anything as the library will do it for you. reconnect: :class:`bool` If we should attempt reconnecting to the gateway, either due to internet failure or a specific failure on Discord's part. Certain From 88484e221c486a01d6f0eabdbd3034e03d9c757c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 11 Dec 2024 16:19:54 +0100 Subject: [PATCH 11/37] chore: Update Client.__aenter__ and Client.run --- discord/client.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/discord/client.py b/discord/client.py index 137c1e1de2..78c1ba5afc 100644 --- a/discord/client.py +++ b/discord/client.py @@ -267,10 +267,20 @@ def __init__( self._tasks = set() async def __aenter__(self) -> Client: - loop = asyncio.get_running_loop() - self.loop = loop - self.http.loop = loop - self._connection.loop = loop + if self.loop is MISSING: + try: + self.loop = asyncio.get_running_loop() + except RuntimeError: + # No event loop was found, this should not happen + # because entering on this context manager means a + # loop is already active, but we need to handle it + # anyways just to prevent future errors. + + # Maybe handle different system event loop policies? + self.loop = asyncio.new_event_loop() + + self.http.loop = self.loop + self._connection.loop = self.loop self._ready = asyncio.Event() @@ -749,11 +759,6 @@ async def start(self, token: str, *, reconnect: bool = True) -> None: An unexpected keyword argument was received. """ # Update the loop to get the running one in case the one set is MISSING - if self.loop is MISSING: - self.loop = asyncio.get_event_loop() - self.http.loop = self.loop - self._connection.loop = self.loop - await self.login(token) await self.connect(reconnect=reconnect) @@ -791,11 +796,8 @@ def run(self, token: str, *, reconnect: bool = True) -> None: """ async def runner(): - try: - await self.start(token, reconnect=reconnect) - finally: - if not self.is_closed(): - await self.close() + async with self: + await self.start(token=token, reconnect=reconnect) run = asyncio.run From ba81ebeb469053385882b129e782c48d34c7f08b Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 11 Dec 2024 16:20:52 +0100 Subject: [PATCH 12/37] feat: Add operations container to Client docstring --- discord/client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/discord/client.py b/discord/client.py index 78c1ba5afc..19053c2eb4 100644 --- a/discord/client.py +++ b/discord/client.py @@ -121,6 +121,12 @@ class Client: A number of options can be passed to the :class:`Client`. + .. container:: operations + + .. describe:: async with x + + Asynchronously initializes the client. + Parameters ----------- max_messages: Optional[:class:`int`] From 28fab357755bf85cfce07c5d696adf8c47551e4d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 11 Dec 2024 16:30:57 +0100 Subject: [PATCH 13/37] chore: Update Client.close to prevent double closing and race conditions --- discord/client.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/discord/client.py b/discord/client.py index e6832231b6..8c64fe447b 100644 --- a/discord/client.py +++ b/discord/client.py @@ -259,7 +259,8 @@ def __init__( self._enable_debug_events: bool = options.pop("enable_debug_events", False) self._connection: ConnectionState = self._get_state(**options) self._connection.shard_count = self.shard_count - self._closed: bool = False + self._closed: asyncio.Event = asyncio.Event() + self._closing_task: asyncio.Lock = asyncio.Lock() self._ready: asyncio.Event = asyncio.Event() self._connection._get_websocket = self._get_websocket self._connection._get_client = lambda: self @@ -289,6 +290,7 @@ async def __aenter__(self) -> Client: self._connection.loop = self.loop self._ready = asyncio.Event() + self._closed = asyncio.Event() return self @@ -725,23 +727,24 @@ async def close(self) -> None: Closes the connection to Discord. """ - if self._closed: + if self.is_closed(): return - await self.http.close() - self._closed = True + async with self._closing_task: + await self.http.close() - for voice in self.voice_clients: - try: - await voice.disconnect(force=True) - except Exception: - # if an error happens during disconnects, disregard it. - pass + for voice in self.voice_clients: + try: + await voice.disconnect(force=True) + except Exception: + # if an error happens during disconnects, disregard it. + pass - if self.ws is not None and self.ws.open: - await self.ws.close(code=1000) + if self.ws is not None and self.ws.open: + await self.ws.close(code=1000) - self._ready.clear() + self._ready.clear() + self._closed.set() def clear(self) -> None: """Clears the internal state of the bot. @@ -818,14 +821,14 @@ async def runner(): if not self.is_closed(): self.loop.run_until_complete(self.close()) - _log.info("Cleaning up tasks.") - _cleanup_loop(self.loop) + _log.info("Cleaning up tasks.") + _cleanup_loop(self.loop) # properties def is_closed(self) -> bool: """Indicates if the WebSocket connection is closed.""" - return self._closed + return self._closed.is_set() @property def activity(self) -> ActivityTypes | None: From c378d41f9896263dcf4cdb63af6f1466c54f354c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 11 Dec 2024 16:32:36 +0100 Subject: [PATCH 14/37] fix: Indentation error --- discord/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/client.py b/discord/client.py index 8c64fe447b..dcbcd05959 100644 --- a/discord/client.py +++ b/discord/client.py @@ -821,8 +821,8 @@ async def runner(): if not self.is_closed(): self.loop.run_until_complete(self.close()) - _log.info("Cleaning up tasks.") - _cleanup_loop(self.loop) + _log.info("Cleaning up tasks.") + _cleanup_loop(self.loop) # properties From e963341ca9fc0e38e3d4e3ac20b4dc0825598864 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 1 Feb 2025 08:33:22 +0100 Subject: [PATCH 15/37] chore: Update Client.close and Client.clear to correctly update and use Client._closed asyncio.Event object --- discord/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/client.py b/discord/client.py index dcbcd05959..092c0a5163 100644 --- a/discord/client.py +++ b/discord/client.py @@ -727,10 +727,10 @@ async def close(self) -> None: Closes the connection to Discord. """ - if self.is_closed(): - return - async with self._closing_task: + if self.is_closed(): + return + await self.http.close() for voice in self.voice_clients: @@ -753,7 +753,7 @@ def clear(self) -> None: and :meth:`is_ready` both return ``False`` along with the bot's internal cache cleared. """ - self._closed = False + self._closed.clear() self._ready.clear() self._connection.clear() self.http.recreate() From 0bc5014b1e32befcf3d19d7faa1f952dc7eebb1e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 7 Jun 2025 13:52:37 +0200 Subject: [PATCH 16/37] chore: make loop a property --- discord/client.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index a2c7c2a8a8..d9cab723cd 100644 --- a/discord/client.py +++ b/discord/client.py @@ -231,7 +231,7 @@ def __init__( ): # self.ws is set in the connect method self.ws: DiscordWebSocket = None # type: ignore - self.loop: asyncio.AbstractEventLoop = loop + self._loop: asyncio.AbstractEventLoop = loop self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( {} ) @@ -323,6 +323,19 @@ def _get_state(self, **options: Any) -> ConnectionState: def _handle_ready(self) -> None: self._ready.set() + @property + def loop(self) -> asyncio.AbstractEventLoop: + """The event loop that the client uses for asynchronous operations.""" + if self._loop is MISSING: + raise RuntimeError('loop is not set') + return self._loop + + @loop.setter + def loop(self, value: asyncio.AbstractEventLoop) -> None: + if not isinstance(value, asyncio.AbstractEventLoop): + raise TypeError(f'expected a AbstractEventLoop object, got {value.__class__.__name__!r} instead') + self._loop = value + @property def latency(self) -> float: """Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. If no websocket From e60bda3fa0a92a7834f4984c7dbd1b7d654266f3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 7 Jun 2025 11:53:11 +0000 Subject: [PATCH 17/37] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/client.py b/discord/client.py index d9cab723cd..327fae6b4c 100644 --- a/discord/client.py +++ b/discord/client.py @@ -327,13 +327,15 @@ def _handle_ready(self) -> None: def loop(self) -> asyncio.AbstractEventLoop: """The event loop that the client uses for asynchronous operations.""" if self._loop is MISSING: - raise RuntimeError('loop is not set') + raise RuntimeError("loop is not set") return self._loop @loop.setter def loop(self, value: asyncio.AbstractEventLoop) -> None: if not isinstance(value, asyncio.AbstractEventLoop): - raise TypeError(f'expected a AbstractEventLoop object, got {value.__class__.__name__!r} instead') + raise TypeError( + f"expected a AbstractEventLoop object, got {value.__class__.__name__!r} instead" + ) self._loop = value @property From cb05d05df21c62e1d3743a619e731b575c746d3a Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 7 Jun 2025 13:58:16 +0200 Subject: [PATCH 18/37] chore: make cleanup be done only when necessary --- discord/client.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/discord/client.py b/discord/client.py index d9cab723cd..52536c6f1b 100644 --- a/discord/client.py +++ b/discord/client.py @@ -274,9 +274,9 @@ def __init__( self._tasks = set() async def __aenter__(self) -> Client: - if self.loop is MISSING: + if self._loop is MISSING: try: - self.loop = asyncio.get_running_loop() + self._loop = asyncio.get_running_loop() except RuntimeError: # No event loop was found, this should not happen # because entering on this context manager means a @@ -284,7 +284,7 @@ async def __aenter__(self) -> Client: # anyways just to prevent future errors. # Maybe handle different system event loop policies? - self.loop = asyncio.new_event_loop() + self._loop = asyncio.new_event_loop() self.http.loop = self.loop self._connection.loop = self.loop @@ -822,10 +822,12 @@ async def runner(): async with self: await self.start(token=token, reconnect=reconnect) - run = asyncio.run - - if self.loop is not MISSING: + try: run = self.loop.run_until_complete + requires_cleanup = True + except RuntimeError: + run = asyncio.run + requires_cleanup = False try: run(runner()) @@ -834,8 +836,11 @@ async def runner(): if not self.is_closed(): self.loop.run_until_complete(self.close()) - _log.info("Cleaning up tasks.") - _cleanup_loop(self.loop) + # asyncio.run automatically does the cleanup tasks, so if we use + # it we don't need to clean up the tasks. + if requires_cleanup: + _log.info("Cleaning up tasks.") + _cleanup_loop(self.loop) # properties From 3f0783a59788c9a4ed872ecb27293cd7b2abcc74 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 16 Jun 2025 17:12:17 +0200 Subject: [PATCH 19/37] change from = MISSING to = None --- discord/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/client.py b/discord/client.py index d2b7867c04..ea195e735a 100644 --- a/discord/client.py +++ b/discord/client.py @@ -226,12 +226,12 @@ class Client: def __init__( self, *, - loop: asyncio.AbstractEventLoop = MISSING, + loop: asyncio.AbstractEventLoop | None = None, **options: Any, ): # self.ws is set in the connect method self.ws: DiscordWebSocket = None # type: ignore - self._loop: asyncio.AbstractEventLoop = loop + self._loop: asyncio.AbstractEventLoop | None = loop self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( {} ) @@ -326,7 +326,7 @@ def _handle_ready(self) -> None: @property def loop(self) -> asyncio.AbstractEventLoop: """The event loop that the client uses for asynchronous operations.""" - if self._loop is MISSING: + if self._loop is None: raise RuntimeError("loop is not set") return self._loop From 930a0c6c01dd3ee586562a8f6e3809545e7cd9ba Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 26 Jul 2025 17:17:47 +0200 Subject: [PATCH 20/37] fix HTTPClient using loop instead of _loop --- discord/client.py | 4 ++-- discord/http.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/discord/client.py b/discord/client.py index ea195e735a..cff14e2b39 100644 --- a/discord/client.py +++ b/discord/client.py @@ -247,7 +247,7 @@ def __init__( proxy=proxy, proxy_auth=proxy_auth, unsync_clock=unsync_clock, - loop=self.loop, + loop=self._loop, ) self._handlers: dict[str, Callable] = {"ready": self._handle_ready} @@ -274,7 +274,7 @@ def __init__( self._tasks = set() async def __aenter__(self) -> Client: - if self._loop is MISSING: + if self._loop is None: try: self._loop = asyncio.get_running_loop() except RuntimeError: diff --git a/discord/http.py b/discord/http.py index 2db704b268..d93f4a6843 100644 --- a/discord/http.py +++ b/discord/http.py @@ -175,9 +175,7 @@ def __init__( loop: asyncio.AbstractEventLoop | None = None, unsync_clock: bool = True, ) -> None: - self.loop: asyncio.AbstractEventLoop = ( - asyncio.get_event_loop() if loop is None else loop - ) + self.loop: asyncio.AbstractEventLoop = loop or MISSING self.connector = connector self.__session: aiohttp.ClientSession = MISSING # filled in static_login self._locks: weakref.WeakValueDictionary = weakref.WeakValueDictionary() From 11a32b1084f268ca6f6ecfb57c37cd48ed93b1b9 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 30 Aug 2025 23:35:40 +0200 Subject: [PATCH 21/37] fix attribute errors --- discord/client.py | 9 +++++++-- discord/state.py | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/discord/client.py b/discord/client.py index f7ff19dab7..956ea44a6e 100644 --- a/discord/client.py +++ b/discord/client.py @@ -320,7 +320,7 @@ def _get_state(self, **options: Any) -> ConnectionState: handlers=self._handlers, hooks=self._hooks, http=self.http, - loop=self.loop, + loop=self._loop, **options, ) @@ -839,7 +839,12 @@ async def start(self, token: str, *, reconnect: bool = True) -> None: await self.login(token) await self.connect(reconnect=reconnect) - def run(self, token: str, *, reconnect: bool = True) -> None: + def run( + self, + token: str, + *, + reconnect: bool = True, + ) -> None: """A blocking call that abstracts away the event loop initialisation from you. diff --git a/discord/state.py b/discord/state.py index 5543e62f15..72733295db 100644 --- a/discord/state.py +++ b/discord/state.py @@ -95,6 +95,8 @@ CS = TypeVar("CS", bound="ConnectionState") Channel = Union[GuildChannel, VocalGuildChannel, PrivateChannel, PartialMessageable] +MISSING = utils.MISSING + class ChunkRequest: def __init__( @@ -167,10 +169,10 @@ def __init__( handlers: dict[str, Callable], hooks: dict[str, Callable], http: HTTPClient, - loop: asyncio.AbstractEventLoop, + loop: asyncio.AbstractEventLoop | None, **options: Any, ) -> None: - self.loop: asyncio.AbstractEventLoop = loop + self.loop: asyncio.AbstractEventLoop = loop or MISSING self.http: HTTPClient = http self.max_messages: int | None = options.get("max_messages", 1000) if self.max_messages is not None and self.max_messages <= 0: From 83c851796f180b08fe1b629ef8118e0f845d8202 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 31 Aug 2025 23:28:29 +0200 Subject: [PATCH 22/37] fix ext.tasks --- discord/ext/tasks/__init__.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index af34cc6844..5e09039557 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -90,11 +90,13 @@ def __init__( time: datetime.time | Sequence[datetime.time], count: int | None, reconnect: bool, - loop: asyncio.AbstractEventLoop, + loop: asyncio.AbstractEventLoop | None, + name: str | None, ) -> None: self.coro: LF = coro self.reconnect: bool = reconnect - self.loop: asyncio.AbstractEventLoop = loop + self.loop: asyncio.AbstractEventLoop | None = loop + self.name: str = f'pycord-ext-task ({id(self):#x}): {coro.__qualname__}' if name in (None, MISSING) else name self.count: int | None = count self._current_loop = 0 self._handle: SleepHandle = MISSING @@ -145,8 +147,15 @@ async def _call_loop_function(self, name: str, *args: Any, **kwargs: Any) -> Non if name.endswith("_loop"): setattr(self, f"_{name}_running", False) + def _create_task(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]: + if self.loop is None: + meth = asyncio.create_task + else: + meth = self.loop.create_task + return meth(self._loop(*args, **kwargs), name=self.name) + def _try_sleep_until(self, dt: datetime.datetime): - self._handle = SleepHandle(dt=dt, loop=self.loop) + self._handle = SleepHandle(dt=dt, loop=asyncio.get_running_loop()) return self._handle.wait() async def _loop(self, *args: Any, **kwargs: Any) -> None: @@ -218,6 +227,7 @@ def __get__(self, obj: T, objtype: type[T]) -> Loop[LF]: count=self.count, reconnect=self.reconnect, loop=self.loop, + name=self.name, ) copy._injected = obj copy._before_loop = self._before_loop @@ -330,10 +340,7 @@ 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 = asyncio.get_event_loop() - - self._task = self.loop.create_task(self._loop(*args, **kwargs)) + self._task = self._create_task(*args, **kwargs) return self._task def stop(self) -> None: @@ -738,6 +745,7 @@ def loop( count: int | None = None, reconnect: bool = True, loop: asyncio.AbstractEventLoop = MISSING, + name: str | None = MISSING, ) -> Callable[[LF], Loop[LF]]: """A decorator that schedules a task in the background for you with optional reconnect logic. The decorator returns a :class:`Loop`. @@ -793,6 +801,7 @@ def decorator(func: LF) -> Loop[LF]: time=time, reconnect=reconnect, loop=loop, + name=name, ) return decorator From a81fe27a0a8f28b665dca9524b29f732e955a08f Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 31 Aug 2025 23:29:40 +0200 Subject: [PATCH 23/37] docs and None --- discord/ext/tasks/__init__.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 5e09039557..643abda58d 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -744,7 +744,7 @@ def loop( time: datetime.time | Sequence[datetime.time] = MISSING, count: int | None = None, reconnect: bool = True, - loop: asyncio.AbstractEventLoop = MISSING, + loop: asyncio.AbstractEventLoop | None = None, name: str | None = MISSING, ) -> Callable[[LF], Loop[LF]]: """A decorator that schedules a task in the background for you with @@ -778,9 +778,15 @@ def loop( Whether to handle errors and restart the task using an exponential back-off algorithm similar to the one used in :meth:`discord.Client.connect`. - loop: :class:`asyncio.AbstractEventLoop` - The loop to use to register the task, if not given - defaults to :func:`asyncio.get_event_loop`. + loop: Optional[:class:`asyncio.AbstractEventLoop`] + The loop to use to register the task, defaults to ``None``. + + .. versionchanged:: 2.7 + This can now be ``None`` + name: Optional[:class:`str`] + The name to create the task with, defaults to ``None``. + + .. versionadded:: 2.7 Raises ------ From 015bc450f0b1bfa93db08753a109cd1f8335f2ad Mon Sep 17 00:00:00 2001 From: Lala Sabathil Date: Mon, 1 Sep 2025 22:00:35 +0200 Subject: [PATCH 24/37] fix: changelog position Signed-off-by: Lala Sabathil --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6e362894..b678d273a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ These changes are available on the `master` branch, but have not yet been releas - Manage silence for new SSRC with existing user_id. ([#2808](https://github.com/Pycord-Development/pycord/pull/2808)) +- Fixed Async I/O errors that could be raised when using `Client.run`. + ([#2645](https://github.com/Pycord-Development/pycord/pull/2645)) ### Removed @@ -129,8 +131,6 @@ These changes are available on the `master` branch, but have not yet been releas ([#2624](https://github.com/Pycord-Development/pycord/pull/2624)) - Fixed editing `ForumChannel` flags not working. ([#2641](https://github.com/Pycord-Development/pycord/pull/2641)) -- Fixed Async I/O errors that could be raised when using `Client.run`. - ([#2645](https://github.com/Pycord-Development/pycord/pull/2645)) - Fixed `AttributeError` when accessing `Member.guild_permissions` for user installed apps. ([#2650](https://github.com/Pycord-Development/pycord/pull/2650)) - Fixed type annotations of cached properties. From daab9877204b650755cf2b1917708994e8bcf478 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:19:34 +0200 Subject: [PATCH 25/37] fix tasks things --- discord/client.py | 7 ++ discord/ext/tasks/__init__.py | 194 ++++++++++++++++++++-------------- 2 files changed, 121 insertions(+), 80 deletions(-) diff --git a/discord/client.py b/discord/client.py index 956ea44a6e..cc852a8f59 100644 --- a/discord/client.py +++ b/discord/client.py @@ -235,6 +235,13 @@ def __init__( ): # self.ws is set in the connect method self.ws: DiscordWebSocket = None # type: ignore + + if loop is None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + pass + self._loop: asyncio.AbstractEventLoop | None = loop self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( {} diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 643abda58d..ba5d6d2f33 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -28,6 +28,7 @@ import asyncio import datetime import inspect +import logging import sys import traceback from collections.abc import Sequence @@ -43,26 +44,54 @@ T = TypeVar("T") _func = Callable[..., Awaitable[Any]] +_log = logging.getLogger(__name__) LF = TypeVar("LF", bound=_func) FT = TypeVar("FT", bound=_func) ET = TypeVar("ET", bound=Callable[[Any, BaseException], Awaitable[Any]]) +def is_ambiguous(dt: datetime.datetime) -> bool: + if dt.tzinfo is None or isinstance(dt.tzinfo, datetime.timezone): + return False + + before = dt.replace(fold=0) + after = dt.replace(fold=1) + + same_offset = before.utcoffset() == after.utcoffset() + same_dst = before.dst() == after.dst() + return not (same_offset and same_dst) + + +def is_imaginary(dt: datetime.datetime) -> bool: + if dt.tzinfo is None or isinstance(dt.tzinfo, datetime.timezone): + return False + + tz = dt.tzinfo + dt = dt.replace(tzinfo=None) + roundtrip = dt.replace(tzinfo=tz).astimezone(datetime.timezone.utc).astimezone(tz).replace(tzinfo=None) + return dt != roundtrip + + class SleepHandle: __slots__ = ("future", "loop", "handle") def __init__( self, dt: datetime.datetime, *, loop: asyncio.AbstractEventLoop ) -> None: - self.loop = loop - self.future = future = loop.create_future() + self.loop: asyncio.AbstractEventLoop = loop + self.future: asyncio.Future[None] = loop.create_future() relative_delta = discord.utils.compute_timedelta(dt) - self.handle = loop.call_later(relative_delta, future.set_result, True) + self.handle = loop.call_later(relative_delta, self._safe_result, self.future) + + @staticmethod + def _safe_result(future: asyncio.Future) -> None: + if not future.done(): + future.set_result(None) def recalculate(self, dt: datetime.datetime) -> None: self.handle.cancel() relative_delta = discord.utils.compute_timedelta(dt) - self.handle = self.loop.call_later(relative_delta, self.future.set_result, True) + self.handle = self.loop.call_later(relative_delta, self._safe_result, self.future) def wait(self) -> asyncio.Future[Any]: return self.future @@ -95,7 +124,15 @@ def __init__( ) -> None: self.coro: LF = coro self.reconnect: bool = reconnect - self.loop: asyncio.AbstractEventLoop | None = loop + + if loop is None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + + self.loop = loop + self.name: str = f'pycord-ext-task ({id(self):#x}): {coro.__qualname__}' if name in (None, MISSING) else name self.count: int | None = count self._current_loop = 0 @@ -147,53 +184,67 @@ async def _call_loop_function(self, name: str, *args: Any, **kwargs: Any) -> Non if name.endswith("_loop"): setattr(self, f"_{name}_running", False) - def _create_task(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]: - if self.loop is None: - meth = asyncio.create_task - else: - meth = self.loop.create_task - return meth(self._loop(*args, **kwargs), name=self.name) - def _try_sleep_until(self, dt: datetime.datetime): self._handle = SleepHandle(dt=dt, loop=asyncio.get_running_loop()) return self._handle.wait() + def _rel_time(self) -> bool: + return self._time is MISSING + + def _expl_time(self) -> bool: + return self._time is not MISSING + async def _loop(self, *args: Any, **kwargs: Any) -> None: backoff = ExponentialBackoff() await self._call_loop_function("before_loop") self._last_iteration_failed = False - if self._time is not MISSING: - # the time index should be prepared every time the internal loop is started - self._prepare_time_index() + if self._expl_time(): self._next_iteration = self._get_next_sleep_time() else: self._next_iteration = datetime.datetime.now(datetime.timezone.utc) + try: - await self._try_sleep_until(self._next_iteration) + if self._stop_next_iteration: + return + while True: + if self._expl_time(): + await self._try_sleep_until(self._next_iteration) if not self._last_iteration_failed: self._last_iteration = self._next_iteration self._next_iteration = self._get_next_sleep_time() + + while self._expl_time() and self._next_iteration <= self._last_iteration: + _log.warning( + 'Task %s woke up at %s, which was before expected (%s). Sleeping again to fix it...', + self.coro.__name__, + discord.utils.utcnow(), + self._next_iteration, + ) + await self._try_sleep_until(self._next_iteration) + self._next_iteration = self._get_next_sleep_time() try: await self.coro(*args, **kwargs) self._last_iteration_failed = False - backoff = ExponentialBackoff() - except self._valid_exception: + except self._valid_exception as exc: self._last_iteration_failed = True if not self.reconnect: raise - await asyncio.sleep(backoff.delay()) - else: - await self._try_sleep_until(self._next_iteration) + delay = backoff.delay() + _log.warning( + 'Received an exception which was in the valid exception set. Task will run again in %s.2f seconds', + self.coro.__name__, + delay, + exc_info=exc, + ) + await asyncio.sleep(delay) + else: if self._stop_next_iteration: return - now = datetime.datetime.now(datetime.timezone.utc) - if now > self._next_iteration: - self._next_iteration = now - if self._time is not MISSING: - self._prepare_time_index(now) + if self._rel_time(): + await self._try_sleep_until(self._next_iteration) self._current_loop += 1 if self._current_loop == self.count: @@ -208,7 +259,8 @@ async def _loop(self, *args: Any, **kwargs: Any) -> None: raise exc finally: await self._call_loop_function("after_loop") - self._handle.cancel() + if self._handle: + self._handle.cancel() self._is_being_cancelled = False self._current_loop = 0 self._stop_next_iteration = False @@ -226,8 +278,8 @@ def __get__(self, obj: T, objtype: type[T]) -> Loop[LF]: time=self._time, count=self.count, reconnect=self.reconnect, - loop=self.loop, name=self.name, + loop=self.loop, ) copy._injected = obj copy._before_loop = self._before_loop @@ -340,7 +392,7 @@ def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]: if self._injected is not None: args = (self._injected, *args) - self._task = self._create_task(*args, **kwargs) + self._task = self.loop.create_task(self._loop(*args, **kwargs), name=self.name) return self._task def stop(self) -> None: @@ -574,66 +626,51 @@ def error(self, coro: ET) -> ET: self._error = coro # type: ignore return coro - def _get_next_sleep_time(self) -> datetime.datetime: + def _get_next_sleep_time(self, now: datetime.datetime = MISSING) -> datetime.datetime: if self._sleep is not MISSING: return self._last_iteration + datetime.timedelta(seconds=self._sleep) - if self._time_index >= len(self._time): - self._time_index = 0 - if self._current_loop == 0: - # if we're at the last index on the first iteration, we need to sleep until tomorrow - return datetime.datetime.combine( - datetime.datetime.now(self._time[0].tzinfo or datetime.timezone.utc) - + datetime.timedelta(days=1), - self._time[0], - ) + if now is MISSING: + now = datetime.datetime.now(datetime.timezone.utc) - next_time = self._time[self._time_index] - - if self._current_loop == 0: - self._time_index += 1 - if ( - next_time - > datetime.datetime.now( - next_time.tzinfo or datetime.timezone.utc - ).timetz() - ): - return datetime.datetime.combine( - datetime.datetime.now(next_time.tzinfo or datetime.timezone.utc), - next_time, - ) - else: - return datetime.datetime.combine( - datetime.datetime.now(next_time.tzinfo or datetime.timezone.utc) - + datetime.timedelta(days=1), - next_time, - ) + index = self._start_time_relative_to(now) - next_date = cast( - datetime.datetime, self._last_iteration.astimezone(next_time.tzinfo) - ) - if next_time < next_date.timetz(): - next_date += datetime.timedelta(days=1) + if index is None: + time = self._time[0] + tomorrow = now.astimezone(time.tzinfo) + datetime.timedelta(days=1) + date = tomorrow.date() + else: + time = self._time[index] + date = now.astimezone(time.tzinfo).date() + + dt = datetime.datetime.combine(date, time, tzinfo=time.tzinfo) - self._time_index += 1 - return datetime.datetime.combine(next_date, next_time) + if dt.tzinfo is None or isinstance(dt.tzinfo, datetime.timezone): + return dt + + if is_imaginary(dt): + tomorrow = dt + datetime.timedelta(days=1) + yesterday = dt - datetime.timedelta(days=1) + return dt + (tomorrow.utcoffset() - yesterday.utcoffset()) # type: ignore + elif is_ambiguous(dt): + return dt.replace(fold=1) + else: + return dt - def _prepare_time_index(self, now: datetime.datetime = MISSING) -> None: + def _start_time_relative_to(self, now: datetime.datetime) -> int | None: # now kwarg should be a datetime.datetime representing the time "now" # to calculate the next time index from # pre-condition: self._time is set - time_now = ( - now - if now is not MISSING - else datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) - ) for idx, time in enumerate(self._time): - if time >= time_now.astimezone(time.tzinfo).timetz(): - self._time_index = idx - break + # Convert the current time to the target timezone + # e.g. 18:00 UTC -> 03:00 UTC+9 + # Then compare the time instances to see if they're the same + start = now.astimezone(time.tzinfo) + if time >= start.timetz(): + return idx else: - self._time_index = 0 + return None def _get_time_parameter( self, @@ -780,9 +817,6 @@ def loop( one used in :meth:`discord.Client.connect`. loop: Optional[:class:`asyncio.AbstractEventLoop`] The loop to use to register the task, defaults to ``None``. - - .. versionchanged:: 2.7 - This can now be ``None`` name: Optional[:class:`str`] The name to create the task with, defaults to ``None``. @@ -806,8 +840,8 @@ def decorator(func: LF) -> Loop[LF]: count=count, time=time, reconnect=reconnect, - loop=loop, name=name, + loop=loop, ) return decorator From 93e4c19055fee78da205f64ff2039c3f85128a2f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 21:20:10 +0000 Subject: [PATCH 26/37] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ext/tasks/__init__.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index ba5d6d2f33..347dc9a470 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -32,7 +32,7 @@ import sys import traceback from collections.abc import Sequence -from typing import Any, Awaitable, Callable, Generic, TypeVar, cast +from typing import Any, Awaitable, Callable, Generic, TypeVar import aiohttp @@ -68,7 +68,12 @@ def is_imaginary(dt: datetime.datetime) -> bool: tz = dt.tzinfo dt = dt.replace(tzinfo=None) - roundtrip = dt.replace(tzinfo=tz).astimezone(datetime.timezone.utc).astimezone(tz).replace(tzinfo=None) + roundtrip = ( + dt.replace(tzinfo=tz) + .astimezone(datetime.timezone.utc) + .astimezone(tz) + .replace(tzinfo=None) + ) return dt != roundtrip @@ -91,7 +96,9 @@ def _safe_result(future: asyncio.Future) -> None: def recalculate(self, dt: datetime.datetime) -> None: self.handle.cancel() relative_delta = discord.utils.compute_timedelta(dt) - self.handle = self.loop.call_later(relative_delta, self._safe_result, self.future) + self.handle = self.loop.call_later( + relative_delta, self._safe_result, self.future + ) def wait(self) -> asyncio.Future[Any]: return self.future @@ -133,7 +140,11 @@ def __init__( self.loop = loop - self.name: str = f'pycord-ext-task ({id(self):#x}): {coro.__qualname__}' if name in (None, MISSING) else name + self.name: str = ( + f"pycord-ext-task ({id(self):#x}): {coro.__qualname__}" + if name in (None, MISSING) + else name + ) self.count: int | None = count self._current_loop = 0 self._handle: SleepHandle = MISSING @@ -214,9 +225,12 @@ async def _loop(self, *args: Any, **kwargs: Any) -> None: self._last_iteration = self._next_iteration self._next_iteration = self._get_next_sleep_time() - while self._expl_time() and self._next_iteration <= self._last_iteration: + while ( + self._expl_time() + and self._next_iteration <= self._last_iteration + ): _log.warning( - 'Task %s woke up at %s, which was before expected (%s). Sleeping again to fix it...', + "Task %s woke up at %s, which was before expected (%s). Sleeping again to fix it...", self.coro.__name__, discord.utils.utcnow(), self._next_iteration, @@ -233,7 +247,7 @@ async def _loop(self, *args: Any, **kwargs: Any) -> None: delay = backoff.delay() _log.warning( - 'Received an exception which was in the valid exception set. Task will run again in %s.2f seconds', + "Received an exception which was in the valid exception set. Task will run again in %s.2f seconds", self.coro.__name__, delay, exc_info=exc, @@ -626,7 +640,9 @@ def error(self, coro: ET) -> ET: self._error = coro # type: ignore return coro - def _get_next_sleep_time(self, now: datetime.datetime = MISSING) -> datetime.datetime: + def _get_next_sleep_time( + self, now: datetime.datetime = MISSING + ) -> datetime.datetime: if self._sleep is not MISSING: return self._last_iteration + datetime.timedelta(seconds=self._sleep) From aba538b4e3e5a2834ce5da90ae978856f4dd747c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 2 Sep 2025 00:17:56 +0200 Subject: [PATCH 27/37] tasks --- discord/client.py | 26 +++++++++++++++++++++- discord/ext/tasks/__init__.py | 41 ++++++++++++++++++++++++----------- discord/state.py | 6 ++++- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/discord/client.py b/discord/client.py index cc852a8f59..4db5797ff0 100644 --- a/discord/client.py +++ b/discord/client.py @@ -75,6 +75,7 @@ from .soundboard import SoundboardSound from .ui.item import Item from .voice_client import VoiceProtocol + from .ext.tasks import Loop as TaskLoop __all__ = ("Client",) @@ -119,6 +120,27 @@ def _cleanup_loop(loop: asyncio.AbstractEventLoop) -> None: loop.close() +class LoopTaskSet: + def __init__(self) -> None: + self.tasks: set[TaskLoop] = set() + self.client: Client | None = None + + def add_loop(self, loop: TaskLoop) -> None: + if self.client is not None: + running = asyncio.get_running_loop() + loop.loop = running + loop.start() + else: + self.tasks.add(loop) + + def start(self, client: Client) -> None: + self.client = client + for task in self.tasks: + loop = client.loop + task.loop = loop + task.start() + + class Client: r"""Represents a client connection that connects to Discord. This class is used to interact with the Discord WebSocket and API. @@ -227,6 +249,8 @@ class Client: The event loop that the client uses for asynchronous operations. """ + _pending_loops = LoopTaskSet() + def __init__( self, *, @@ -297,6 +321,7 @@ async def __aenter__(self) -> Client: # Maybe handle different system event loop policies? self._loop = asyncio.new_event_loop() + self._pending_loops.start(self) self.http.loop = self.loop self._connection.loop = self.loop @@ -506,7 +531,6 @@ def _schedule_event( return task def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None: - _log.debug("Dispatching event %s", event) method = f"on_{event}" listeners = self._listeners.get(event) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index ba5d6d2f33..b0e59d7832 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -32,7 +32,7 @@ import sys import traceback from collections.abc import Sequence -from typing import Any, Awaitable, Callable, Generic, TypeVar, cast +from typing import Any, Awaitable, Callable, Generic, TypeVar import aiohttp @@ -120,18 +120,19 @@ def __init__( count: int | None, reconnect: bool, loop: asyncio.AbstractEventLoop | None, + create_loop: bool, name: str | None, ) -> None: self.coro: LF = coro self.reconnect: bool = reconnect - if loop is None: + if create_loop is True and loop is None: try: loop = asyncio.get_running_loop() except RuntimeError: loop = asyncio.new_event_loop() - self.loop = loop + self.loop: asyncio.AbstractEventLoop | None = loop self.name: str = f'pycord-ext-task ({id(self):#x}): {coro.__qualname__}' if name in (None, MISSING) else name self.count: int | None = count @@ -146,6 +147,7 @@ def __init__( aiohttp.ClientError, asyncio.TimeoutError, ) + self._create_loop = create_loop self._before_loop = None self._after_loop = None @@ -168,6 +170,9 @@ def __init__( f"Expected coroutine function, not {type(self.coro).__name__!r}." ) + if loop is None and not create_loop: + discord.Client._pending_loops.add_loop(self) + async def _call_loop_function(self, name: str, *args: Any, **kwargs: Any) -> None: coro = getattr(self, f"_{name}") if coro is None: @@ -280,6 +285,7 @@ def __get__(self, obj: T, objtype: type[T]) -> Loop[LF]: reconnect=self.reconnect, name=self.name, loop=self.loop, + create_loop=self._create_loop, ) copy._injected = obj copy._before_loop = self._before_loop @@ -365,7 +371,7 @@ async def __call__(self, *args: Any, **kwargs: Any) -> Any: return await self.coro(*args, **kwargs) - def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]: + def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None] | None: r"""Starts the internal task in the event loop. Parameters @@ -386,13 +392,21 @@ def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]: The task that has been created. """ + if self.loop is None: + _log.warning( + f"The task {self.name} has been set to be bound to a discord.Client instance, and will start running automatically " + "when the client starts. If you want this task to be executed without it being bound to a discord.Client, " + "set the create_loop parameter in the decorator to True, and don't forget to set the client.loop to the loop.loop" + ) + return None + if self._task is not MISSING and not self._task.done(): raise RuntimeError("Task is already launched and is not completed.") if self._injected is not None: args = (self._injected, *args) - self._task = self.loop.create_task(self._loop(*args, **kwargs), name=self.name) + self._task = asyncio.ensure_future(self.loop.create_task(self._loop(*args, **kwargs), name=self.name)) return self._task def stop(self) -> None: @@ -760,15 +774,9 @@ def change_interval( self._time = self._get_time_parameter(time) self._sleep = self._seconds = self._minutes = self._hours = MISSING - if self.is_running() and not ( - self._before_loop_running or self._after_loop_running - ): - if self._time is not MISSING: - # prepare the next time index starting from after the last iteration - self._prepare_time_index(now=self._last_iteration) - + if self.is_running() and self._last_iteration is not MISSING: self._next_iteration = self._get_next_sleep_time() - if not self._handle.done(): + if self._handle and not self._handle.done(): # the loop is sleeping, recalculate based on new interval self._handle.recalculate(self._next_iteration) @@ -783,6 +791,7 @@ def loop( reconnect: bool = True, loop: asyncio.AbstractEventLoop | None = None, name: str | None = MISSING, + create_loop: bool = False, ) -> Callable[[LF], Loop[LF]]: """A decorator that schedules a task in the background for you with optional reconnect logic. The decorator returns a :class:`Loop`. @@ -820,6 +829,11 @@ def loop( name: Optional[:class:`str`] The name to create the task with, defaults to ``None``. + .. versionadded:: 2.7 + create_loop: :class:`bool` + Whether this task should create their own event loop to start running it + without a client bound to it. + .. versionadded:: 2.7 Raises @@ -842,6 +856,7 @@ def decorator(func: LF) -> Loop[LF]: reconnect=reconnect, name=name, loop=loop, + create_loop=create_loop, ) return decorator diff --git a/discord/state.py b/discord/state.py index 72733295db..da055244bc 100644 --- a/discord/state.py +++ b/discord/state.py @@ -178,7 +178,7 @@ def __init__( if self.max_messages is not None and self.max_messages <= 0: self.max_messages = 1000 - self.dispatch: Callable = dispatch + self._dispatch: Callable = dispatch self.handlers: dict[str, Callable] = handlers self.hooks: dict[str, Callable] = hooks self.shard_count: int | None = None @@ -263,6 +263,10 @@ def __init__( self.clear() + def dispatch(self, event: str, *args: Any, **kwargs: Any) -> Any: + _log.debug('Dispatching event %s', event) + return self._dispatch(event, *args, **kwargs) + def clear(self, *, views: bool = True) -> None: self.user: ClientUser | None = None # Originally, this code used WeakValueDictionary to maintain references to the From 70185185f565b8ed2ac898d2570833c003569de8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:19:01 +0000 Subject: [PATCH 28/37] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/client.py | 2 +- discord/ext/tasks/__init__.py | 4 +++- discord/state.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/discord/client.py b/discord/client.py index 4db5797ff0..4c8886d5bc 100644 --- a/discord/client.py +++ b/discord/client.py @@ -68,6 +68,7 @@ if TYPE_CHECKING: from .abc import GuildChannel, PrivateChannel, Snowflake, SnowflakeTime from .channel import DMChannel + from .ext.tasks import Loop as TaskLoop from .interactions import Interaction from .member import Member from .message import Message @@ -75,7 +76,6 @@ from .soundboard import SoundboardSound from .ui.item import Item from .voice_client import VoiceProtocol - from .ext.tasks import Loop as TaskLoop __all__ = ("Client",) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 84d4018ccd..4be9746311 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -420,7 +420,9 @@ def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None] | None: if self._injected is not None: args = (self._injected, *args) - self._task = asyncio.ensure_future(self.loop.create_task(self._loop(*args, **kwargs), name=self.name)) + self._task = asyncio.ensure_future( + self.loop.create_task(self._loop(*args, **kwargs), name=self.name) + ) return self._task def stop(self) -> None: diff --git a/discord/state.py b/discord/state.py index da055244bc..4d4481bd37 100644 --- a/discord/state.py +++ b/discord/state.py @@ -264,7 +264,7 @@ def __init__( self.clear() def dispatch(self, event: str, *args: Any, **kwargs: Any) -> Any: - _log.debug('Dispatching event %s', event) + _log.debug("Dispatching event %s", event) return self._dispatch(event, *args, **kwargs) def clear(self, *, views: bool = True) -> None: From 7d21354848510f7c586255e4eb171c2eaf7cf310 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 2 Sep 2025 00:20:19 +0200 Subject: [PATCH 29/37] loop= --- discord/ext/tasks/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 4be9746311..b7d2dbb4aa 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -421,7 +421,8 @@ def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None] | None: args = (self._injected, *args) self._task = asyncio.ensure_future( - self.loop.create_task(self._loop(*args, **kwargs), name=self.name) + self.loop.create_task(self._loop(*args, **kwargs), name=self.name), + loop=self.loop, ) return self._task From 8c035cb5edd2e2f789c46c0705593478c3f49c5d Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 2 Sep 2025 01:45:26 +0200 Subject: [PATCH 30/37] replace asyncio.iscoroutinefunction refs (deprecated) with inspect.iscoroutinefunction --- discord/bot.py | 4 ++-- discord/client.py | 7 ++++--- discord/commands/core.py | 12 ++++++------ discord/ext/commands/core.py | 8 ++++---- discord/utils.py | 3 ++- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index 7dd246afe3..b5ee86422d 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -1386,7 +1386,7 @@ def before_invoke(self, coro): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not inspect.iscoroutinefunction(coro): raise TypeError("The pre-invoke hook must be a coroutine.") self._before_invoke = coro @@ -1418,7 +1418,7 @@ def after_invoke(self, coro): The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not inspect.iscoroutinefunction(coro): raise TypeError("The post-invoke hook must be a coroutine.") self._after_invoke = coro diff --git a/discord/client.py b/discord/client.py index 4c8886d5bc..07e11edc01 100644 --- a/discord/client.py +++ b/discord/client.py @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio +import inspect import logging import sys import traceback @@ -1396,7 +1397,7 @@ async def my_message(message): pass if not name.startswith("on_"): raise ValueError("The 'name' parameter must start with 'on_'") - if not asyncio.iscoroutinefunction(func): + if not inspect.iscoroutinefunction(func): raise TypeError("Listeners must be coroutines") if name in self._event_handlers: @@ -1476,7 +1477,7 @@ def decorator(func: Coro) -> Coro: self.add_listener(func, name) return func - if asyncio.iscoroutinefunction(name): + if inspect.iscoroutinefunction(name): coro = name name = coro.__name__ return decorator(coro) @@ -1511,7 +1512,7 @@ async def on_ready(): print('Ready!') """ - if not asyncio.iscoroutinefunction(coro): + if not inspect.iscoroutinefunction(coro): raise TypeError("event registered must be a coroutine function") setattr(self, coro.__name__, coro) diff --git a/discord/commands/core.py b/discord/commands/core.py index 90e7c5aa60..e30cccfe25 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -503,7 +503,7 @@ def error(self, coro): The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not inspect.iscoroutinefunction(coro): raise TypeError("The error handler must be a coroutine.") self.on_error = coro @@ -532,7 +532,7 @@ def before_invoke(self, coro): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not inspect.iscoroutinefunction(coro): raise TypeError("The pre-invoke hook must be a coroutine.") self._before_invoke = coro @@ -557,7 +557,7 @@ def after_invoke(self, coro): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not inspect.iscoroutinefunction(coro): raise TypeError("The post-invoke hook must be a coroutine.") self._after_invoke = coro @@ -734,7 +734,7 @@ def __new__(cls, *args, **kwargs) -> SlashCommand: def __init__(self, func: Callable, *args, **kwargs) -> None: super().__init__(func, **kwargs) - if not asyncio.iscoroutinefunction(func): + if not inspect.iscoroutinefunction(func): raise TypeError("Callback must be a coroutine.") self.callback = func @@ -1125,7 +1125,7 @@ async def invoke_autocomplete_callback(self, ctx: AutocompleteContext): else: result = option.autocomplete(ctx) - if asyncio.iscoroutinefunction(option.autocomplete): + if inspect.iscoroutinefunction(option.autocomplete): result = await result choices = [ @@ -1653,7 +1653,7 @@ def __new__(cls, *args, **kwargs) -> ContextMenuCommand: def __init__(self, func: Callable, *args, **kwargs) -> None: super().__init__(func, **kwargs) - if not asyncio.iscoroutinefunction(func): + if not inspect.iscoroutinefunction(func): raise TypeError("Callback must be a coroutine.") self.callback = func diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 1a0d8a09a2..a86634fcaf 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -325,7 +325,7 @@ def __init__( ), **kwargs: Any, ): - if not asyncio.iscoroutinefunction(func): + if not inspect.iscoroutinefunction(func): raise TypeError("Callback must be a coroutine.") name = kwargs.get("name") or func.__name__ @@ -993,7 +993,7 @@ def error(self, coro: ErrorT) -> ErrorT: The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not inspect.iscoroutinefunction(coro): raise TypeError("The error handler must be a coroutine.") self.on_error: Error = coro @@ -1027,7 +1027,7 @@ def before_invoke(self, coro: HookT) -> HookT: TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not inspect.iscoroutinefunction(coro): raise TypeError("The pre-invoke hook must be a coroutine.") self._before_invoke = coro @@ -1054,7 +1054,7 @@ def after_invoke(self, coro: HookT) -> HookT: TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not inspect.iscoroutinefunction(coro): raise TypeError("The post-invoke hook must be a coroutine.") self._after_invoke = coro diff --git a/discord/utils.py b/discord/utils.py index c42a51cbd8..ba1e8f3840 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -31,6 +31,7 @@ import datetime import functools import importlib.resources +import inspect import itertools import json import logging @@ -1377,7 +1378,7 @@ def _filter(ctx: AutocompleteContext, item: Any) -> bool: gen = (val for val in _values if _filter(ctx, val)) - elif asyncio.iscoroutinefunction(filter): + elif inspect.iscoroutinefunction(filter): gen = (val for val in _values if await filter(ctx, val)) elif callable(filter): From 68a929f9d9dfb7c73c0af0044b9afaff638ccf91 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:07:19 +0200 Subject: [PATCH 31/37] make start also update loop if not already updated via __aenter__ --- discord/client.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 07e11edc01..6615bd5568 100644 --- a/discord/client.py +++ b/discord/client.py @@ -301,6 +301,7 @@ def __init__( self._connection._get_websocket = self._get_websocket self._connection._get_client = lambda: self self._event_handlers: dict[str, list[Coro]] = {} + self._in_context: bool = False if VoiceClient.warn_nacl: VoiceClient.warn_nacl = False @@ -310,6 +311,7 @@ def __init__( self._tasks = set() async def __aenter__(self) -> Client: + self._in_context = True if self._loop is None: try: self._loop = asyncio.get_running_loop() @@ -337,6 +339,7 @@ async def __aexit__( exc_v: BaseException | None, exc_tb: TracebackType | None, ) -> None: + self._in_context = False if not self.is_closed(): await self.close() @@ -867,7 +870,20 @@ async def start(self, token: str, *, reconnect: bool = True) -> None: TypeError An unexpected keyword argument was received. """ - # Update the loop to get the running one in case the one set is MISSING + if not self._in_context and self._loop is None: + # Update the loop to get the running one in case the one set is MISSING + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + self._loop = asyncio.new_event_loop() + + self._pending_loops.start(self) + self.http.loop = self.loop + self._connection.loop = self.loop + + self._ready = asyncio.Event() + self._closed = asyncio.Event() + await self.login(token) await self.connect(reconnect=reconnect) From 2d0b61a873f855928a3a3b145159e8cdceb0a146 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 14:20:21 +0000 Subject: [PATCH 32/37] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 6615bd5568..fe17e464e9 100644 --- a/discord/client.py +++ b/discord/client.py @@ -876,7 +876,7 @@ async def start(self, token: str, *, reconnect: bool = True) -> None: self._loop = asyncio.get_running_loop() except RuntimeError: self._loop = asyncio.new_event_loop() - + self._pending_loops.start(self) self.http.loop = self.loop self._connection.loop = self.loop From 6ad8c28d42b0f4ede662027512ac585f79a92f54 Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 9 Oct 2025 12:52:20 +0200 Subject: [PATCH 33/37] :recycle: Use `contextlib.suppress` instead of `except: pass` Signed-off-by: Paillat --- discord/client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/discord/client.py b/discord/client.py index fe17e464e9..bcee17ed7a 100644 --- a/discord/client.py +++ b/discord/client.py @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio +import contextlib import inspect import logging import sys @@ -262,10 +263,8 @@ def __init__( self.ws: DiscordWebSocket = None # type: ignore if loop is None: - try: + with contextlib.suppress(RuntimeError): loop = asyncio.get_running_loop() - except RuntimeError: - pass self._loop: asyncio.AbstractEventLoop | None = loop self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( From 626993076445143f81917c3f853e203727926a61 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:03:49 +0200 Subject: [PATCH 34/37] fix % str Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: DA344 <108473820+DA-344@users.noreply.github.com> --- discord/ext/tasks/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index b7d2dbb4aa..db955ec555 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -252,7 +252,7 @@ async def _loop(self, *args: Any, **kwargs: Any) -> None: delay = backoff.delay() _log.warning( - "Received an exception which was in the valid exception set. Task will run again in %s.2f seconds", + "Received an exception which was in the valid exception set. Task will run again in %.2f seconds", self.coro.__name__, delay, exc_info=exc, From 753f3310c14718b0ed8674fea7362b3629220042 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 18 Oct 2025 23:14:12 +0200 Subject: [PATCH 35/37] fix errors --- discord/client.py | 62 ++++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/discord/client.py b/discord/client.py index 6615bd5568..1a2741d5b5 100644 --- a/discord/client.py +++ b/discord/client.py @@ -301,7 +301,8 @@ def __init__( self._connection._get_websocket = self._get_websocket self._connection._get_client = lambda: self self._event_handlers: dict[str, list[Coro]] = {} - self._in_context: bool = False + self._setup_done: asyncio.Event = asyncio.Event() + self._setup_lock: asyncio.Lock = asyncio.Lock() if VoiceClient.warn_nacl: VoiceClient.warn_nacl = False @@ -310,27 +311,32 @@ def __init__( # Used to hard-reference tasks so they don't get garbage collected (discarded with done_callbacks) self._tasks = set() - async def __aenter__(self) -> Client: - self._in_context = True - if self._loop is None: - try: - self._loop = asyncio.get_running_loop() - except RuntimeError: - # No event loop was found, this should not happen - # because entering on this context manager means a - # loop is already active, but we need to handle it - # anyways just to prevent future errors. + async def _async_setup(self) -> None: + async with self._setup_lock: + if self._setup_done.is_set(): + return - # Maybe handle different system event loop policies? - self._loop = asyncio.new_event_loop() + if self._loop is None: + try: + l = asyncio.get_running_loop() + except RuntimeError: + # No event loop was found, this should not happen + # because entering on this context manager means a + # loop is already active, but we need to handle it + # anyways just to prevent future errors. + l = asyncio.new_event_loop() + + self._loop = l + self.http.loop = l + self._connection.loop = l - self._pending_loops.start(self) - self.http.loop = self.loop - self._connection.loop = self.loop + self._ready = asyncio.Event() + self._closed = asyncio.Event() - self._ready = asyncio.Event() - self._closed = asyncio.Event() + self._setup_done.set() + async def __aenter__(self) -> Client: + await self._async_setup() return self async def __aexit__( @@ -339,7 +345,6 @@ async def __aexit__( exc_v: BaseException | None, exc_tb: TracebackType | None, ) -> None: - self._in_context = False if not self.is_closed(): await self.close() @@ -721,6 +726,7 @@ async def login(self, token: str) -> None: f"token must be of type str, not {token.__class__.__name__}" ) + await self._async_setup() _log.info("logging in using static token") data = await self.http.static_login(token.strip()) @@ -751,6 +757,8 @@ async def connect(self, *, reconnect: bool = True) -> None: The WebSocket connection has been terminated. """ + await self._async_setup() + backoff = ExponentialBackoff() ws_params = { "initial": True, @@ -847,6 +855,7 @@ async def close(self) -> None: self._ready.clear() self._closed.set() + self._setup_done.clear() def clear(self) -> None: """Clears the internal state of the bot. @@ -857,6 +866,7 @@ def clear(self) -> None: """ self._closed.clear() self._ready.clear() + self._setup_done.clear() self._connection.clear() self.http.recreate() @@ -870,20 +880,6 @@ async def start(self, token: str, *, reconnect: bool = True) -> None: TypeError An unexpected keyword argument was received. """ - if not self._in_context and self._loop is None: - # Update the loop to get the running one in case the one set is MISSING - try: - self._loop = asyncio.get_running_loop() - except RuntimeError: - self._loop = asyncio.new_event_loop() - - self._pending_loops.start(self) - self.http.loop = self.loop - self._connection.loop = self.loop - - self._ready = asyncio.Event() - self._closed = asyncio.Event() - await self.login(token) await self.connect(reconnect=reconnect) From 1dba0d11136f19d456d6485d880770d7cbe290ce Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 18 Oct 2025 23:21:37 +0200 Subject: [PATCH 36/37] more things --- discord/client.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/discord/client.py b/discord/client.py index 3581f92cc8..df2a52243f 100644 --- a/discord/client.py +++ b/discord/client.py @@ -26,7 +26,6 @@ from __future__ import annotations import asyncio -import contextlib import inspect import logging import sys @@ -52,7 +51,7 @@ from .invite import Invite from .iterators import EntitlementIterator, GuildIterator from .mentions import AllowedMentions -from .monetization import SKU, Entitlement +from .monetization import SKU from .object import Object from .soundboard import SoundboardSound from .stage_instance import StageInstance @@ -262,10 +261,6 @@ def __init__( # self.ws is set in the connect method self.ws: DiscordWebSocket = None # type: ignore - if loop is None: - with contextlib.suppress(RuntimeError): - loop = asyncio.get_running_loop() - self._loop: asyncio.AbstractEventLoop | None = loop self._listeners: dict[str, list[tuple[asyncio.Future, Callable[..., bool]]]] = ( {} From 93046038b03b633518771c28bb33061e922b0f9c Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 18 Oct 2025 23:30:16 +0200 Subject: [PATCH 37/37] improve docstrings --- discord/ext/tasks/__init__.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index db955ec555..d4cd1a7502 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio +import contextlib import datetime import inspect import logging @@ -133,11 +134,12 @@ def __init__( self.coro: LF = coro self.reconnect: bool = reconnect - if create_loop is True and loop is None: + if loop is None: try: loop = asyncio.get_running_loop() except RuntimeError: - loop = asyncio.new_event_loop() + if create_loop: + loop = asyncio.new_event_loop() self.loop: asyncio.AbstractEventLoop | None = loop @@ -388,6 +390,11 @@ async def __call__(self, *args: Any, **kwargs: Any) -> Any: def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None] | None: r"""Starts the internal task in the event loop. + If this loop was created with the ``create_loop`` parameter set as ``False`` and + no running loop is found (eg this method is not called from an async context), + then this task will be started automatically when any kind of :class:`~discord.Client` + (subclasses included) starts. + Parameters ------------ \*args @@ -406,6 +413,13 @@ def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None] | None: The task that has been created. """ + loop = None + with contextlib.suppress(RuntimeError): + loop = asyncio.get_running_loop() + + if loop: + self.loop = loop + if self.loop is None: _log.warning( f"The task {self.name} has been set to be bound to a discord.Client instance, and will start running automatically " @@ -850,8 +864,13 @@ def loop( .. versionadded:: 2.7 create_loop: :class:`bool` - Whether this task should create their own event loop to start running it - without a client bound to it. + Whether this task should create its own :class:`asyncio.AbstractEventLoop` to run if + no already running one is found. + + Loops must be in an async context in order to run, this means :meth:`Loop.start` should be + called from an async context (e.g. coroutines). + + Defaults to ``False``. .. versionadded:: 2.7