From 060bad105dc2569fc485adb03b985aa2ab5d367e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 22 Feb 2022 23:47:09 +0000 Subject: Move new utilities to the util namespace --- botcore/utils/scheduling.py | 246 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 botcore/utils/scheduling.py (limited to 'botcore/utils/scheduling.py') diff --git a/botcore/utils/scheduling.py b/botcore/utils/scheduling.py new file mode 100644 index 00000000..947df0d9 --- /dev/null +++ b/botcore/utils/scheduling.py @@ -0,0 +1,246 @@ +"""Generic python scheduler.""" + +import asyncio +import contextlib +import inspect +import typing +from datetime import datetime +from functools import partial + +from botcore.utils import loggers + + +class Scheduler: + """ + Schedule the execution of coroutines and keep track of them. + + When instantiating a Scheduler, a name must be provided. This name is used to distinguish the + instance's log messages from other instances. Using the name of the class or module containing + the instance is suggested. + + Coroutines can be scheduled immediately with `schedule` or in the future with `schedule_at` + or `schedule_later`. A unique ID is required to be given in order to keep track of the + resulting Tasks. Any scheduled task can be cancelled prematurely using `cancel` by providing + the same ID used to schedule it. The `in` operator is supported for checking if a task with a + given ID is currently scheduled. + + Any exception raised in a scheduled task is logged when the task is done. + """ + + def __init__(self, name: str): + """ + Initialize a new Scheduler instance. + + Args: + name: The name of the scheduler. Used in logging, and namespacing. + """ + self.name = name + + self._log = loggers.get_logger(f"{__name__}.{name}") + self._scheduled_tasks: typing.Dict[typing.Hashable, asyncio.Task] = {} + + def __contains__(self, task_id: typing.Hashable) -> bool: + """ + Return True if a task with the given `task_id` is currently scheduled. + + Args: + task_id: The task to look for. + + Returns: + True if the task was found. + """ + return task_id in self._scheduled_tasks + + def schedule(self, task_id: typing.Hashable, coroutine: typing.Coroutine) -> None: + """ + Schedule the execution of a `coroutine`. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. + + Args: + task_id: A unique ID to create the task with. + coroutine: The function to be called. + """ + self._log.trace(f"Scheduling task #{task_id}...") + + msg = f"Cannot schedule an already started coroutine for #{task_id}" + assert inspect.getcoroutinestate(coroutine) == "CORO_CREATED", msg + + if task_id in self._scheduled_tasks: + self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") + coroutine.close() + return + + task = asyncio.create_task(coroutine, name=f"{self.name}_{task_id}") + task.add_done_callback(partial(self._task_done_callback, task_id)) + + self._scheduled_tasks[task_id] = task + self._log.debug(f"Scheduled task #{task_id} {id(task)}.") + + def schedule_at(self, time: datetime, task_id: typing.Hashable, coroutine: typing.Coroutine) -> None: + """ + Schedule `coroutine` to be executed at the given `time`. + + If `time` is timezone aware, then use that timezone to calculate now() when subtracting. + If `time` is naïve, then use UTC. + + If `time` is in the past, schedule `coroutine` immediately. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. + + Args: + time: The time to start the task. + task_id: A unique ID to create the task with. + coroutine: The function to be called. + """ + now_datetime = datetime.now(time.tzinfo) if time.tzinfo else datetime.utcnow() + delay = (time - now_datetime).total_seconds() + if delay > 0: + coroutine = self._await_later(delay, task_id, coroutine) + + self.schedule(task_id, coroutine) + + def schedule_later( + self, + delay: typing.Union[int, float], + task_id: typing.Hashable, + coroutine: typing.Coroutine + ) -> None: + """ + Schedule `coroutine` to be executed after `delay` seconds. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. + + Args: + delay: How long to wait before starting the task. + task_id: A unique ID to create the task with. + coroutine: The function to be called. + """ + self.schedule(task_id, self._await_later(delay, task_id, coroutine)) + + def cancel(self, task_id: typing.Hashable) -> None: + """ + Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist. + + Args: + task_id: The task's unique ID. + """ + self._log.trace(f"Cancelling task #{task_id}...") + + try: + task = self._scheduled_tasks.pop(task_id) + except KeyError: + self._log.warning(f"Failed to unschedule {task_id} (no task found).") + else: + task.cancel() + + self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") + + def cancel_all(self) -> None: + """Unschedule all known tasks.""" + self._log.debug("Unscheduling all tasks") + + for task_id in self._scheduled_tasks.copy(): + self.cancel(task_id) + + async def _await_later( + self, + delay: typing.Union[int, float], + task_id: typing.Hashable, + coroutine: typing.Coroutine + ) -> None: + """Await `coroutine` after `delay` seconds.""" + try: + self._log.trace(f"Waiting {delay} seconds before awaiting coroutine for #{task_id}.") + await asyncio.sleep(delay) + + # Use asyncio.shield to prevent the coroutine from cancelling itself. + self._log.trace(f"Done waiting for #{task_id}; now awaiting the coroutine.") + await asyncio.shield(coroutine) + finally: + # Close it to prevent unawaited coroutine warnings, + # which would happen if the task was cancelled during the sleep. + # Only close it if it's not been awaited yet. This check is important because the + # coroutine may cancel this task, which would also trigger the finally block. + state = inspect.getcoroutinestate(coroutine) + if state == "CORO_CREATED": + self._log.debug(f"Explicitly closing the coroutine for #{task_id}.") + coroutine.close() + else: + self._log.debug(f"Finally block reached for #{task_id}; {state=}") + + def _task_done_callback(self, task_id: typing.Hashable, done_task: asyncio.Task) -> None: + """ + Delete the task and raise its exception if one exists. + + If `done_task` and the task associated with `task_id` are different, then the latter + will not be deleted. In this case, a new task was likely rescheduled with the same ID. + """ + self._log.trace(f"Performing done callback for task #{task_id} {id(done_task)}.") + + scheduled_task = self._scheduled_tasks.get(task_id) + + if scheduled_task and done_task is scheduled_task: + # A task for the ID exists and is the same as the done task. + # Since this is the done callback, the task is already done so no need to cancel it. + self._log.trace(f"Deleting task #{task_id} {id(done_task)}.") + del self._scheduled_tasks[task_id] + elif scheduled_task: + # A new task was likely rescheduled with the same ID. + self._log.debug( + f"The scheduled task #{task_id} {id(scheduled_task)} " + f"and the done task {id(done_task)} differ." + ) + elif not done_task.cancelled(): + self._log.warning( + f"Task #{task_id} not found while handling task {id(done_task)}! " + f"A task somehow got unscheduled improperly (i.e. deleted but not cancelled)." + ) + + with contextlib.suppress(asyncio.CancelledError): + exception = done_task.exception() + # Log the exception if one exists. + if exception: + self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) + + +def create_task( + coro: typing.Awaitable, + *, + suppressed_exceptions: tuple[typing.Type[Exception]] = (), + event_loop: typing.Optional[asyncio.AbstractEventLoop] = None, + **kwargs, +) -> asyncio.Task: + """ + Wrapper for creating asyncio `Tasks` which logs exceptions raised in the task. + + If the loop kwarg is provided, the task is created from that event loop, otherwise the running loop is used. + + Args: + coro: The function to call. + suppressed_exceptions: Exceptions to be handled by the task. + event_loop (:obj:`asyncio.AbstractEventLoop`): The loop to create the task from. + kwargs: Passed to :py:func:`asyncio.create_task`. + + Returns: + asyncio.Task: The wrapped task. + """ + if event_loop is not None: + task = event_loop.create_task(coro, **kwargs) + else: + task = asyncio.create_task(coro, **kwargs) + task.add_done_callback(partial(_log_task_exception, suppressed_exceptions=suppressed_exceptions)) + return task + + +def _log_task_exception(task: asyncio.Task, *, suppressed_exceptions: typing.Tuple[typing.Type[Exception]]) -> None: + """Retrieve and log the exception raised in `task` if one exists.""" + with contextlib.suppress(asyncio.CancelledError): + exception = task.exception() + # Log the exception if one exists. + if exception and not isinstance(exception, suppressed_exceptions): + log = loggers.get_logger(__name__) + log.error(f"Error in task {task.get_name()} {id(task)}!", exc_info=exception) -- cgit v1.2.3 From aa2f9685c29d46a2666654c545d4461763c903b6 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 23 Feb 2022 00:59:09 +0000 Subject: Alter docstrings to look better in autodocs --- botcore/utils/channel.py | 13 +++++++++++-- botcore/utils/extensions.py | 4 ++-- botcore/utils/loggers.py | 10 +++++----- botcore/utils/members.py | 14 +++++++++++--- botcore/utils/scheduling.py | 2 +- 5 files changed, 30 insertions(+), 13 deletions(-) (limited to 'botcore/utils/scheduling.py') diff --git a/botcore/utils/channel.py b/botcore/utils/channel.py index 7e0fc387..6a630c94 100644 --- a/botcore/utils/channel.py +++ b/botcore/utils/channel.py @@ -1,4 +1,4 @@ -"""Utilities for interacting with discord channels.""" +"""Useful helper functions for interacting with various discord.py channel objects.""" import discord from discord.ext.commands import Bot @@ -9,7 +9,16 @@ log = loggers.get_logger(__name__) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: - """Return True if `channel` is within a category with `category_id`.""" + """ + Return whether the given channel has the category_id. + + Args: + channel: The channel to check. + category_id: The category to check for. + + Returns: + A bool depending on whether the channel is in the category. + """ return getattr(channel, "category_id", None) == category_id diff --git a/botcore/utils/extensions.py b/botcore/utils/extensions.py index c8f200ad..3f8d6e6d 100644 --- a/botcore/utils/extensions.py +++ b/botcore/utils/extensions.py @@ -9,7 +9,7 @@ from typing import NoReturn def unqualify(name: str) -> str: """ - Return an unqualified name given a qualified module/package `name`. + Return an unqualified name given a qualified module/package ``name``. Args: name: The module name to unqualify. @@ -22,7 +22,7 @@ def unqualify(name: str) -> str: def walk_extensions(module: types.ModuleType) -> frozenset[str]: """ - Yield extension names from the bot.exts subpackage. + Yield extension names from the given module. Args: module (types.ModuleType): The module to look for extensions in. diff --git a/botcore/utils/loggers.py b/botcore/utils/loggers.py index ac1db920..740c20d4 100644 --- a/botcore/utils/loggers.py +++ b/botcore/utils/loggers.py @@ -1,4 +1,4 @@ -"""Custom logging class.""" +"""Custom :obj:`logging.Logger` class that implements a new ``"TRACE"`` level.""" import logging import typing @@ -12,11 +12,11 @@ TRACE_LEVEL = 5 class CustomLogger(LoggerClass): - """Custom implementation of the `Logger` class with an added `trace` method.""" + """Custom implementation of the :obj:`logging.Logger` class with an added :obj:`trace` method.""" def trace(self, msg: str, *args, **kwargs) -> None: """ - Log 'msg % args' with severity 'TRACE'. + Log the given message with the severity ``"TRACE"``. To pass exception information, use the keyword argument exc_info with a true value: @@ -34,12 +34,12 @@ class CustomLogger(LoggerClass): def get_logger(name: typing.Optional[str] = None) -> CustomLogger: """ - Utility to make mypy recognise that logger is of type `CustomLogger`. + Utility to make mypy recognise that logger is of type :obj:`CustomLogger`. Args: name: The name given to the logger. Returns: - An instance of the `CustomLogger` class. + An instance of the :obj:`CustomLogger` class. """ return typing.cast(CustomLogger, logging.getLogger(name)) diff --git a/botcore/utils/members.py b/botcore/utils/members.py index abe7e5e1..e7b31342 100644 --- a/botcore/utils/members.py +++ b/botcore/utils/members.py @@ -1,3 +1,5 @@ +"""Useful helper functions for interactin with :obj:`discord.Member` objects.""" + import typing import discord @@ -11,7 +13,8 @@ async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> typing.Op """ Attempt to get a member from cache; on failure fetch from the API. - Return `None` to indicate the member could not be found. + Returns: + The :obj:`discord.Member` or :obj:`None` to indicate the member could not be found. """ if member := guild.get_member(member_id): log.trace(f"{member} retrieved from cache.") @@ -31,9 +34,14 @@ async def handle_role_change( role: discord.Role ) -> None: """ - Change `member`'s cooldown role via awaiting `coro` and handle errors. + Await the given ``coro`` with ``member`` as the sole argument. + + Handle errors that we expect to be raised from + :obj:`discord.Member.add_roles` and :obj:`discord.Member.remove_roles`. - `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + Args: + member: The member to pass to ``coro``. + coro: This is intended to be :obj:`discord.Member.add_roles` or :obj:`discord.Member.remove_roles`. """ try: await coro(role) diff --git a/botcore/utils/scheduling.py b/botcore/utils/scheduling.py index 947df0d9..d6969302 100644 --- a/botcore/utils/scheduling.py +++ b/botcore/utils/scheduling.py @@ -215,7 +215,7 @@ def create_task( **kwargs, ) -> asyncio.Task: """ - Wrapper for creating asyncio `Tasks` which logs exceptions raised in the task. + Wrapper for creating an :obj:`asyncio.Task` which logs exceptions raised in the task. If the loop kwarg is provided, the task is created from that event loop, otherwise the running loop is used. -- cgit v1.2.3 From f7dac414b098900b340b2c36b0e69fce6b6c69ba Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 24 Feb 2022 16:10:47 +0000 Subject: Rename loggers.py to logging.py to allow for more generic utils in future --- botcore/utils/__init__.py | 4 ++-- botcore/utils/channel.py | 4 ++-- botcore/utils/loggers.py | 45 --------------------------------------------- botcore/utils/logging.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ botcore/utils/members.py | 4 ++-- botcore/utils/scheduling.py | 6 +++--- 6 files changed, 54 insertions(+), 54 deletions(-) delete mode 100644 botcore/utils/loggers.py create mode 100644 botcore/utils/logging.py (limited to 'botcore/utils/scheduling.py') diff --git a/botcore/utils/__init__.py b/botcore/utils/__init__.py index 554e8ad1..71354334 100644 --- a/botcore/utils/__init__.py +++ b/botcore/utils/__init__.py @@ -1,12 +1,12 @@ """Useful utilities and tools for discord bot development.""" -from botcore.utils import (caching, channel, extensions, loggers, members, regex, scheduling) +from botcore.utils import (caching, channel, extensions, logging, members, regex, scheduling) __all__ = [ caching, channel, extensions, - loggers, + logging, members, regex, scheduling, diff --git a/botcore/utils/channel.py b/botcore/utils/channel.py index 6a630c94..db155911 100644 --- a/botcore/utils/channel.py +++ b/botcore/utils/channel.py @@ -3,9 +3,9 @@ import discord from discord.ext.commands import Bot -from botcore.utils import loggers +from botcore.utils import logging -log = loggers.get_logger(__name__) +log = logging.get_logger(__name__) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: diff --git a/botcore/utils/loggers.py b/botcore/utils/loggers.py deleted file mode 100644 index 740c20d4..00000000 --- a/botcore/utils/loggers.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Custom :obj:`logging.Logger` class that implements a new ``"TRACE"`` level.""" - -import logging -import typing - -if typing.TYPE_CHECKING: - LoggerClass = logging.Logger -else: - LoggerClass = logging.getLoggerClass() - -TRACE_LEVEL = 5 - - -class CustomLogger(LoggerClass): - """Custom implementation of the :obj:`logging.Logger` class with an added :obj:`trace` method.""" - - def trace(self, msg: str, *args, **kwargs) -> None: - """ - Log the given message with the severity ``"TRACE"``. - - To pass exception information, use the keyword argument exc_info with a true value: - - .. code-block:: py - - logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) - - Args: - msg: The message to be logged. - args, kwargs: Passed to the base log function as is. - """ - if self.isEnabledFor(TRACE_LEVEL): - self.log(TRACE_LEVEL, msg, *args, **kwargs) - - -def get_logger(name: typing.Optional[str] = None) -> CustomLogger: - """ - Utility to make mypy recognise that logger is of type :obj:`CustomLogger`. - - Args: - name: The name given to the logger. - - Returns: - An instance of the :obj:`CustomLogger` class. - """ - return typing.cast(CustomLogger, logging.getLogger(name)) diff --git a/botcore/utils/logging.py b/botcore/utils/logging.py new file mode 100644 index 00000000..740c20d4 --- /dev/null +++ b/botcore/utils/logging.py @@ -0,0 +1,45 @@ +"""Custom :obj:`logging.Logger` class that implements a new ``"TRACE"`` level.""" + +import logging +import typing + +if typing.TYPE_CHECKING: + LoggerClass = logging.Logger +else: + LoggerClass = logging.getLoggerClass() + +TRACE_LEVEL = 5 + + +class CustomLogger(LoggerClass): + """Custom implementation of the :obj:`logging.Logger` class with an added :obj:`trace` method.""" + + def trace(self, msg: str, *args, **kwargs) -> None: + """ + Log the given message with the severity ``"TRACE"``. + + To pass exception information, use the keyword argument exc_info with a true value: + + .. code-block:: py + + logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) + + Args: + msg: The message to be logged. + args, kwargs: Passed to the base log function as is. + """ + if self.isEnabledFor(TRACE_LEVEL): + self.log(TRACE_LEVEL, msg, *args, **kwargs) + + +def get_logger(name: typing.Optional[str] = None) -> CustomLogger: + """ + Utility to make mypy recognise that logger is of type :obj:`CustomLogger`. + + Args: + name: The name given to the logger. + + Returns: + An instance of the :obj:`CustomLogger` class. + """ + return typing.cast(CustomLogger, logging.getLogger(name)) diff --git a/botcore/utils/members.py b/botcore/utils/members.py index e7b31342..e89b4618 100644 --- a/botcore/utils/members.py +++ b/botcore/utils/members.py @@ -4,9 +4,9 @@ import typing import discord -from botcore.utils import loggers +from botcore.utils import logging -log = loggers.get_logger(__name__) +log = logging.get_logger(__name__) async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> typing.Optional[discord.Member]: diff --git a/botcore/utils/scheduling.py b/botcore/utils/scheduling.py index d6969302..e2952e6c 100644 --- a/botcore/utils/scheduling.py +++ b/botcore/utils/scheduling.py @@ -7,7 +7,7 @@ import typing from datetime import datetime from functools import partial -from botcore.utils import loggers +from botcore.utils import logging class Scheduler: @@ -36,7 +36,7 @@ class Scheduler: """ self.name = name - self._log = loggers.get_logger(f"{__name__}.{name}") + self._log = logging.get_logger(f"{__name__}.{name}") self._scheduled_tasks: typing.Dict[typing.Hashable, asyncio.Task] = {} def __contains__(self, task_id: typing.Hashable) -> bool: @@ -242,5 +242,5 @@ def _log_task_exception(task: asyncio.Task, *, suppressed_exceptions: typing.Tup exception = task.exception() # Log the exception if one exists. if exception and not isinstance(exception, suppressed_exceptions): - log = loggers.get_logger(__name__) + log = logging.get_logger(__name__) log.error(f"Error in task {task.get_name()} {id(task)}!", exc_info=exception) -- cgit v1.2.3 From 54e3d222deb92ba89072589477114d7d9ceec382 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 24 Feb 2022 16:12:21 +0000 Subject: Consistently use double backticks when referring to a variable name. Also add sphix-style docstrings to functions that were previously missing them. --- botcore/utils/caching.py | 2 +- botcore/utils/channel.py | 23 +++++++++++++++++++-- botcore/utils/logging.py | 2 +- botcore/utils/scheduling.py | 50 +++++++++++++++++++++++---------------------- 4 files changed, 49 insertions(+), 28 deletions(-) (limited to 'botcore/utils/scheduling.py') diff --git a/botcore/utils/caching.py b/botcore/utils/caching.py index ea71ed1d..ac34bb9b 100644 --- a/botcore/utils/caching.py +++ b/botcore/utils/caching.py @@ -16,7 +16,7 @@ class AsyncCache: def __init__(self, max_size: int = 128): """ - Initialise a new AsyncCache instance. + Initialise a new :obj:`AsyncCache` instance. Args: max_size: How many items to store in the cache. diff --git a/botcore/utils/channel.py b/botcore/utils/channel.py index db155911..17e70a2a 100644 --- a/botcore/utils/channel.py +++ b/botcore/utils/channel.py @@ -10,7 +10,7 @@ log = logging.get_logger(__name__) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: """ - Return whether the given channel has the category_id. + Return whether the given ``channel`` in the the category with the id ``category_id``. Args: channel: The channel to check. @@ -23,7 +23,26 @@ def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: async def get_or_fetch_channel(bot: Bot, channel_id: int) -> discord.abc.GuildChannel: - """Attempt to get or fetch a channel and return it.""" + """ + Attempt to get or fetch the given ``channel_id`` from the bots cache, and return it. + + Args: + bot: The :obj:`discord.ext.commands.Bot` instance to use for getting/fetching. + channel_id: The channel to get/fetch. + + Raises: + :exc:`discord.InvalidData` + An unknown channel type was received from Discord. + :exc:`discord.HTTPException` + Retrieving the channel failed. + :exc:`discord.NotFound` + Invalid Channel ID. + :exc:`discord.Forbidden` + You do not have permission to fetch this channel. + + Returns: + The channel from the ID. + """ log.trace(f"Getting the channel {channel_id}.") channel = bot.get_channel(channel_id) diff --git a/botcore/utils/logging.py b/botcore/utils/logging.py index 740c20d4..71ce4e66 100644 --- a/botcore/utils/logging.py +++ b/botcore/utils/logging.py @@ -1,4 +1,4 @@ -"""Custom :obj:`logging.Logger` class that implements a new ``"TRACE"`` level.""" +"""Common logging related functions.""" import logging import typing diff --git a/botcore/utils/scheduling.py b/botcore/utils/scheduling.py index e2952e6c..164f6b10 100644 --- a/botcore/utils/scheduling.py +++ b/botcore/utils/scheduling.py @@ -14,25 +14,26 @@ class Scheduler: """ Schedule the execution of coroutines and keep track of them. - When instantiating a Scheduler, a name must be provided. This name is used to distinguish the + When instantiating a :obj:`Scheduler`, a name must be provided. This name is used to distinguish the instance's log messages from other instances. Using the name of the class or module containing the instance is suggested. - Coroutines can be scheduled immediately with `schedule` or in the future with `schedule_at` - or `schedule_later`. A unique ID is required to be given in order to keep track of the - resulting Tasks. Any scheduled task can be cancelled prematurely using `cancel` by providing - the same ID used to schedule it. The `in` operator is supported for checking if a task with a - given ID is currently scheduled. + Coroutines can be scheduled immediately with :obj:`schedule` or in the future with :obj:`schedule_at` + or :obj:`schedule_later`. A unique ID is required to be given in order to keep track of the + resulting Tasks. Any scheduled task can be cancelled prematurely using :obj:`cancel` by providing + the same ID used to schedule it. + + The ``in`` operator is supported for checking if a task with a given ID is currently scheduled. Any exception raised in a scheduled task is logged when the task is done. """ def __init__(self, name: str): """ - Initialize a new Scheduler instance. + Initialize a new :obj:`Scheduler` instance. Args: - name: The name of the scheduler. Used in logging, and namespacing. + name: The name of the :obj:`Scheduler`. Used in logging, and namespacing. """ self.name = name @@ -41,21 +42,21 @@ class Scheduler: def __contains__(self, task_id: typing.Hashable) -> bool: """ - Return True if a task with the given `task_id` is currently scheduled. + Return :obj:`True` if a task with the given ``task_id`` is currently scheduled. Args: task_id: The task to look for. Returns: - True if the task was found. + :obj:`True` if the task was found. """ return task_id in self._scheduled_tasks def schedule(self, task_id: typing.Hashable, coroutine: typing.Coroutine) -> None: """ - Schedule the execution of a `coroutine`. + Schedule the execution of a ``coroutine``. - If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + If a task with ``task_id`` already exists, close ``coroutine`` instead of scheduling it. This prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. Args: @@ -80,14 +81,14 @@ class Scheduler: def schedule_at(self, time: datetime, task_id: typing.Hashable, coroutine: typing.Coroutine) -> None: """ - Schedule `coroutine` to be executed at the given `time`. + Schedule ``coroutine`` to be executed at the given ``time``. - If `time` is timezone aware, then use that timezone to calculate now() when subtracting. - If `time` is naïve, then use UTC. + If ``time`` is timezone aware, then use that timezone to calculate now() when subtracting. + If ``time`` is naïve, then use UTC. - If `time` is in the past, schedule `coroutine` immediately. + If ``time`` is in the past, schedule ``coroutine`` immediately. - If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + If a task with ``task_id`` already exists, close ``coroutine`` instead of scheduling it. This prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. Args: @@ -109,9 +110,9 @@ class Scheduler: coroutine: typing.Coroutine ) -> None: """ - Schedule `coroutine` to be executed after `delay` seconds. + Schedule ``coroutine`` to be executed after ``delay`` seconds. - If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + If a task with ``task_id`` already exists, close ``coroutine`` instead of scheduling it. This prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. Args: @@ -123,7 +124,7 @@ class Scheduler: def cancel(self, task_id: typing.Hashable) -> None: """ - Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist. + Unschedule the task identified by ``task_id``. Log a warning if the task doesn't exist. Args: task_id: The task's unique ID. @@ -152,7 +153,7 @@ class Scheduler: task_id: typing.Hashable, coroutine: typing.Coroutine ) -> None: - """Await `coroutine` after `delay` seconds.""" + """Await ``coroutine`` after ``delay`` seconds.""" try: self._log.trace(f"Waiting {delay} seconds before awaiting coroutine for #{task_id}.") await asyncio.sleep(delay) @@ -176,7 +177,7 @@ class Scheduler: """ Delete the task and raise its exception if one exists. - If `done_task` and the task associated with `task_id` are different, then the latter + If ``done_task`` and the task associated with ``task_id`` are different, then the latter will not be deleted. In this case, a new task was likely rescheduled with the same ID. """ self._log.trace(f"Performing done callback for task #{task_id} {id(done_task)}.") @@ -217,7 +218,8 @@ def create_task( """ Wrapper for creating an :obj:`asyncio.Task` which logs exceptions raised in the task. - If the loop kwarg is provided, the task is created from that event loop, otherwise the running loop is used. + If the ``event_loop`` kwarg is provided, the task is created from that event loop, + otherwise the running loop is used. Args: coro: The function to call. @@ -237,7 +239,7 @@ def create_task( def _log_task_exception(task: asyncio.Task, *, suppressed_exceptions: typing.Tuple[typing.Type[Exception]]) -> None: - """Retrieve and log the exception raised in `task` if one exists.""" + """Retrieve and log the exception raised in ``task`` if one exists.""" with contextlib.suppress(asyncio.CancelledError): exception = task.exception() # Log the exception if one exists. -- cgit v1.2.3