From 8bd3706994843b31900975825e19aec35641e92d Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 23 Mar 2022 21:39:43 +0000 Subject: Add BotBase that will act as a base for all our bots This commit also modifies the extensions util, since it's now directly used by bot-core. Co-authored-by: Mark <1515135+MarkKoz@users.noreply.github.com> Co-authored-by: Hassan Abouelela --- botcore/_bot.py | 265 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 botcore/_bot.py (limited to 'botcore/_bot.py') diff --git a/botcore/_bot.py b/botcore/_bot.py new file mode 100644 index 00000000..28f5a1a2 --- /dev/null +++ b/botcore/_bot.py @@ -0,0 +1,265 @@ +import asyncio +import socket +import types +from abc import abstractmethod +from contextlib import suppress +from typing import Optional + +import aiohttp +import discord +from async_rediscache import RedisSession +from discord.ext import commands + +from botcore.async_stats import AsyncStatsClient +from botcore.site_api import APIClient +from botcore.utils._extensions import walk_extensions +from botcore.utils.logging import get_logger + +log = get_logger() + + +class StartupError(Exception): + """Exception class for startup errors.""" + + def __init__(self, base: Exception): + super().__init__() + self.exception = base + + +class BotBase(commands.Bot): + """A sub-class that implements many common features that Python Discord bots use.""" + + def __init__( + self, + *args, + guild_id: int, + prefix: str, + allowed_roles: list, + intents: discord.Intents, + http_session: aiohttp.ClientSession, + redis_session: Optional[RedisSession] = None, + **kwargs, + ): + """ + Initialise the base bot instance. + + Args: + guild_id: The ID of the guild use for :func:`wait_until_guild_available`. + prefix: The prefix to use for the bot. + allowed_roles: A list of role IDs that the bot is allowed to mention. + intents: The :obj:`discord.Intents` to use for the bot. + http_session (aiohttp.ClientSession): The session to use for the bot. + redis_session: The + ``[async_rediscache.RedisSession](https://github.com/SebastiaanZ/async-rediscache#creating-a-redissession)`` + to use for the bot. + """ + super().__init__( + *args, + prefix=prefix, + allowed_roles=allowed_roles, + intents=intents, + **kwargs, + ) + + self.guild_id = guild_id + self.http_session = http_session + if redis_session: + self.redis_session = redis_session + + self.api_client: Optional[APIClient] = None + + self._resolver = aiohttp.AsyncResolver() + self._connector = aiohttp.TCPConnector( + resolver=self._resolver, + family=socket.AF_INET, + ) + self.http.connector = self._connector + + self.statsd_url: Optional[str] = None + self._statsd_timerhandle: Optional[asyncio.TimerHandle] = None + self._guild_available = asyncio.Event() + + self.stats: Optional[AsyncStatsClient] = None + + def _connect_statsd( + self, + statsd_url: str, + loop: asyncio.AbstractEventLoop, + retry_after: int = 2, + attempt: int = 1 + ) -> None: + """Callback used to retry a connection to statsd if it should fail.""" + if attempt >= 8: + log.error("Reached 8 attempts trying to reconnect AsyncStatsClient. Aborting") + return + + try: + self.stats = AsyncStatsClient(loop, statsd_url, 8125, prefix="bot") + except socket.gaierror: + log.warning(f"Statsd client failed to connect (Attempt(s): {attempt})") + # Use a fallback strategy for retrying, up to 8 times. + self._statsd_timerhandle = loop.call_later( + retry_after, + self._connect_statsd, + statsd_url, + retry_after * 2, + attempt + 1 + ) + + # All tasks that need to block closing until finished + self.closing_tasks: list[asyncio.Task] = [] + + async def load_extensions(self, module: types.ModuleType) -> None: + """Load all the extensions within the given module.""" + for extension in walk_extensions(module): + await self.load_extension(extension) + + def _add_root_aliases(self, command: commands.Command) -> None: + """Recursively add root aliases for ``command`` and any of its subcommands.""" + if isinstance(command, commands.Group): + for subcommand in command.commands: + self._add_root_aliases(subcommand) + + for alias in getattr(command, "root_aliases", ()): + if alias in self.all_commands: + raise commands.CommandRegistrationError(alias, alias_conflict=True) + + self.all_commands[alias] = command + + def _remove_root_aliases(self, command: commands.Command) -> None: + """Recursively remove root aliases for ``command`` and any of its subcommands.""" + if isinstance(command, commands.Group): + for subcommand in command.commands: + self._remove_root_aliases(subcommand) + + for alias in getattr(command, "root_aliases", ()): + self.all_commands.pop(alias, None) + + async def add_cog(self, cog: commands.Cog) -> None: + """Adds the given ``cog`` to the bot and logs the operation.""" + await super().add_cog(cog) + log.info(f"Cog loaded: {cog.qualified_name}") + + def add_command(self, command: commands.Command) -> None: + """Add ``command`` as normal and then add its root aliases to the bot.""" + super().add_command(command) + self._add_root_aliases(command) + + def remove_command(self, name: str) -> Optional[commands.Command]: + """ + Remove a command/alias as normal and then remove its root aliases from the bot. + + Individual root aliases cannot be removed by this function. + To remove them, either remove the entire command or manually edit `bot.all_commands`. + """ + command = super().remove_command(name) + if command is None: + # Even if it's a root alias, there's no way to get the Bot instance to remove the alias. + return None + + self._remove_root_aliases(command) + return command + + def clear(self) -> None: + """Not implemented! Re-instantiate the bot instead of attempting to re-use a closed one.""" + raise NotImplementedError("Re-using a Bot object after closing it is not supported.") + + async def on_guild_unavailable(self, guild: discord.Guild) -> None: + """Clear the internal guild available event when self.guild_id becomes unavailable.""" + if guild.id != self.guild_id: + return + + self._guild_available.clear() + + async def on_guild_available(self, guild: discord.Guild) -> None: + """ + Set the internal guild available event when self.guild_id becomes available. + + If the cache appears to still be empty (no members, no channels, or no roles), the event + will not be set and `guild_available_but_cache_empty` event will be emitted. + """ + if guild.id != self.guild_id: + return + + if not guild.roles or not guild.members or not guild.channels: + msg = "Guild available event was dispatched but the cache appears to still be empty!" + self.log_to_dev_log(msg) + return + + self._guild_available.set() + + @abstractmethod + async def log_to_dev_log(self, message: str) -> None: + """Log the given message to #dev-log.""" + ... + + async def wait_until_guild_available(self) -> None: + """ + Wait until the guild that matches the ``guild_id`` given at init is available (and the cache is ready). + + The on_ready event is inadequate because it only waits 2 seconds for a GUILD_CREATE + gateway event before giving up and thus not populating the cache for unavailable guilds. + """ + await self._guild_available.wait() + + async def setup_hook(self) -> None: + """ + An async init to startup generic services. + + Connects to statsd, and calls + :func:`AsyncStatsClient.create_socket ` + and :func:`ping_services`. + """ + loop = asyncio.get_running_loop() + self._connect_statsd(self.statsd_url, loop) + self.stats = AsyncStatsClient(loop, "127.0.0.1") + await self.stats.create_socket() + + try: + await self.ping_services() + except Exception as e: + raise StartupError(e) + + @abstractmethod + async def ping_services() -> None: + """Ping all required services on setup to ensure they are up before starting.""" + ... + + async def close(self) -> None: + """Close the Discord connection, and the aiohttp session, connector, statsd client, and resolver.""" + # Done before super().close() to allow tasks finish before the HTTP session closes. + for ext in list(self.extensions): + with suppress(Exception): + await self.unload_extension(ext) + + for cog in list(self.cogs): + with suppress(Exception): + await self.remove_cog(cog) + + # Wait until all tasks that have to be completed before bot is closing is done + log.trace("Waiting for tasks before closing.") + await asyncio.gather(*self.closing_tasks) + + # Now actually do full close of bot + await super().close() + + if self.api_client: + await self.api_client.close() + + if self.http_session: + await self.http_session.close() + + if self._connector: + await self._connector.close() + + if self._resolver: + await self._resolver.close() + + if self.stats._transport: + self.stats._transport.close() + + if self.redis_session: + await self.redis_session.close() + + if self._statsd_timerhandle: + self._statsd_timerhandle.cancel() -- cgit v1.2.3 From 90796054965b4ed44fce636c7b64181958357574 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 24 Mar 2022 07:43:57 +0000 Subject: Remove unneeded kwargs in BotBase Since *args and **kwargs already allow these to be passed, there is no use in explicitly listing them. allowed_roles has been left there, to ensure that it is always passed, since it's important to use this to avoid bots being able to ping large roles. --- botcore/_bot.py | 6 ------ 1 file changed, 6 deletions(-) (limited to 'botcore/_bot.py') diff --git a/botcore/_bot.py b/botcore/_bot.py index 28f5a1a2..f334631c 100644 --- a/botcore/_bot.py +++ b/botcore/_bot.py @@ -33,9 +33,7 @@ class BotBase(commands.Bot): self, *args, guild_id: int, - prefix: str, allowed_roles: list, - intents: discord.Intents, http_session: aiohttp.ClientSession, redis_session: Optional[RedisSession] = None, **kwargs, @@ -45,9 +43,7 @@ class BotBase(commands.Bot): Args: guild_id: The ID of the guild use for :func:`wait_until_guild_available`. - prefix: The prefix to use for the bot. allowed_roles: A list of role IDs that the bot is allowed to mention. - intents: The :obj:`discord.Intents` to use for the bot. http_session (aiohttp.ClientSession): The session to use for the bot. redis_session: The ``[async_rediscache.RedisSession](https://github.com/SebastiaanZ/async-rediscache#creating-a-redissession)`` @@ -55,9 +51,7 @@ class BotBase(commands.Bot): """ super().__init__( *args, - prefix=prefix, allowed_roles=allowed_roles, - intents=intents, **kwargs, ) -- cgit v1.2.3 From b8a245061689c45ebee05f27481837576a079e54 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 24 Mar 2022 07:44:50 +0000 Subject: Provide a bot.all_extensions instance attribute This allows commands like extensions and source to see all of the available commands, rather than just the currently loaded commands. --- botcore/_bot.py | 8 ++++++-- botcore/utils/_extensions.py | 11 +++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) (limited to 'botcore/_bot.py') diff --git a/botcore/_bot.py b/botcore/_bot.py index f334631c..d740fe03 100644 --- a/botcore/_bot.py +++ b/botcore/_bot.py @@ -75,6 +75,8 @@ class BotBase(commands.Bot): self.stats: Optional[AsyncStatsClient] = None + self.all_extensions: Optional[frozenset[str]] = None + def _connect_statsd( self, statsd_url: str, @@ -104,8 +106,10 @@ class BotBase(commands.Bot): self.closing_tasks: list[asyncio.Task] = [] async def load_extensions(self, module: types.ModuleType) -> None: - """Load all the extensions within the given module.""" - for extension in walk_extensions(module): + """Load all the extensions within the given module and save them to ``self.all_extensions``.""" + self.all_extensions = walk_extensions(module) + + for extension in self.all_extensions: await self.load_extension(extension) def _add_root_aliases(self, command: commands.Command) -> None: diff --git a/botcore/utils/_extensions.py b/botcore/utils/_extensions.py index 6848fae6..d90a25dd 100644 --- a/botcore/utils/_extensions.py +++ b/botcore/utils/_extensions.py @@ -22,19 +22,20 @@ def unqualify(name: str) -> str: def walk_extensions(module: types.ModuleType) -> frozenset[str]: """ - Yield extension names from the given module. + Return all extension names from the given module. Args: module (types.ModuleType): The module to look for extensions in. Returns: - An iterator object, that returns a string that can be passed directly to - :obj:`discord.ext.commands.Bot.load_extension` on call to next(). + A set of strings that can be passed directly to :obj:`discord.ext.commands.Bot.load_extension`. """ def on_error(name: str) -> NoReturn: raise ImportError(name=name) # pragma: no cover + modules = set() + for module_info in pkgutil.walk_packages(module.__path__, f"{module.__name__}.", onerror=on_error): if unqualify(module_info.name).startswith("_"): # Ignore module/package names starting with an underscore. @@ -46,4 +47,6 @@ def walk_extensions(module: types.ModuleType) -> frozenset[str]: # If it lacks a setup function, it's not an extension. continue - yield module_info.name + modules.add(module_info.name) + + return frozenset(modules) -- cgit v1.2.3 From 2383754743b9a00d55c35e93efa5b14643ddd366 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 24 Mar 2022 08:15:52 +0000 Subject: Mark async-rediscache as an optional extra dependency --- botcore/_bot.py | 15 ++++++++++++--- poetry.lock | 37 ++++++++++++++++++++----------------- pyproject.toml | 7 +++++-- 3 files changed, 37 insertions(+), 22 deletions(-) (limited to 'botcore/_bot.py') diff --git a/botcore/_bot.py b/botcore/_bot.py index d740fe03..d21ed6b9 100644 --- a/botcore/_bot.py +++ b/botcore/_bot.py @@ -1,13 +1,13 @@ import asyncio import socket import types +import warnings from abc import abstractmethod from contextlib import suppress from typing import Optional import aiohttp import discord -from async_rediscache import RedisSession from discord.ext import commands from botcore.async_stats import AsyncStatsClient @@ -15,6 +15,11 @@ from botcore.site_api import APIClient from botcore.utils._extensions import walk_extensions from botcore.utils.logging import get_logger +try: + from async_rediscache import RedisSession +except ImportError: + RedisSession = discord.utils._MissingSentinel + log = get_logger() @@ -57,7 +62,10 @@ class BotBase(commands.Bot): self.guild_id = guild_id self.http_session = http_session - if redis_session: + + if redis_session and RedisSession == discord.utils._MissingSentinel: + warnings.warn("redis_session kwarg passed, but async-rediscache not installed!") + elif redis_session: self.redis_session = redis_session self.api_client: Optional[APIClient] = None @@ -209,6 +217,7 @@ class BotBase(commands.Bot): and :func:`ping_services`. """ loop = asyncio.get_running_loop() + self._connect_statsd(self.statsd_url, loop) self.stats = AsyncStatsClient(loop, "127.0.0.1") await self.stats.create_socket() @@ -256,7 +265,7 @@ class BotBase(commands.Bot): if self.stats._transport: self.stats._transport.close() - if self.redis_session: + if getattr(self.redis_session, None): await self.redis_session.close() if self._statsd_timerhandle: diff --git a/poetry.lock b/poetry.lock index dd607032..ac5d103e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -23,7 +23,7 @@ name = "aioredis" version = "2.0.1" description = "asyncio (PEP 3156) Redis support" category = "main" -optional = false +optional = true python-versions = ">=3.6" [package.dependencies] @@ -57,7 +57,7 @@ name = "async-rediscache" version = "0.2.0" description = "An easy to use asynchronous Redis cache" category = "main" -optional = false +optional = true python-versions = "~=3.7" [package.dependencies] @@ -177,7 +177,7 @@ name = "deprecated" version = "1.2.13" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." category = "main" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] @@ -238,7 +238,7 @@ name = "fakeredis" version = "1.7.1" description = "Fake implementation of redis API for testing purposes." category = "main" -optional = false +optional = true python-versions = ">=3.5" [package.dependencies] @@ -466,11 +466,11 @@ python-versions = "*" [[package]] name = "jinja2" -version = "3.0.3" +version = "3.1.1" description = "A very fast and expressive template engine." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] MarkupSafe = ">=2.0" @@ -483,7 +483,7 @@ name = "lupa" version = "1.13" description = "Python wrapper around Lua and LuaJIT" category = "main" -optional = false +optional = true python-versions = "*" [[package]] @@ -755,7 +755,7 @@ name = "redis" version = "4.1.4" description = "Python client for Redis database and key-value store" category = "main" -optional = false +optional = true python-versions = ">=3.6" [package.dependencies] @@ -813,7 +813,7 @@ name = "sortedcontainers" version = "2.4.0" description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" category = "main" -optional = false +optional = true python-versions = "*" [[package]] @@ -985,7 +985,7 @@ name = "typing-extensions" version = "4.1.1" description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" -optional = false +optional = true python-versions = ">=3.6" [[package]] @@ -1003,7 +1003,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.13.4" +version = "20.14.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -1024,7 +1024,7 @@ name = "wrapt" version = "1.14.0" description = "Module for decorators, wrappers and monkey patching." category = "main" -optional = false +optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [[package]] @@ -1051,10 +1051,13 @@ python-versions = ">=3.7" docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +[extras] +async-rediscache = ["async-rediscache"] + [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "00840d04789ee82c20b8c9633be5d2333427c2694850c457491cd9180fe39fa1" +content-hash = "d4ef1ae44135c6e669f1711cc55b04314d498d9c90c3329d8f5064d7a1b0ea4f" [metadata.files] aiohttp = [ @@ -1380,8 +1383,8 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ - {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, - {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, + {file = "Jinja2-3.1.1-py3-none-any.whl", hash = "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119"}, + {file = "Jinja2-3.1.1.tar.gz", hash = "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"}, ] lupa = [ {file = "lupa-1.13-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:da1885faca29091f9e408c0cc6b43a0b29a2128acf8d08c188febc5d9f99129d"}, @@ -1788,8 +1791,8 @@ urllib3 = [ {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, ] virtualenv = [ - {file = "virtualenv-20.13.4-py2.py3-none-any.whl", hash = "sha256:c3e01300fb8495bc00ed70741f5271fc95fed067eb7106297be73d30879af60c"}, - {file = "virtualenv-20.13.4.tar.gz", hash = "sha256:ce8901d3bbf3b90393498187f2d56797a8a452fb2d0d7efc6fd837554d6f679c"}, + {file = "virtualenv-20.14.0-py2.py3-none-any.whl", hash = "sha256:1e8588f35e8b42c6ec6841a13c5e88239de1e6e4e4cedfd3916b306dc826ec66"}, + {file = "virtualenv-20.14.0.tar.gz", hash = "sha256:8e5b402037287126e81ccde9432b95a8be5b19d36584f64957060a3488c11ca8"}, ] wrapt = [ {file = "wrapt-1.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:5a9a1889cc01ed2ed5f34574c90745fab1dd06ec2eee663e8ebeefe363e8efd7"}, diff --git a/pyproject.toml b/pyproject.toml index d4d8ceb8..9626a2a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,11 @@ exclude = ["tests", "tests.*"] [tool.poetry.dependencies] python = "3.9.*" "discord.py" = {url = "https://github.com/Rapptz/discord.py/archive/414759f3723a3fe632ecfc1343a4164a51cd2940.zip"} -async-rediscache = { version = "0.2.0", extras = ["fakeredis"] } -statsd = "3.3.0" +async-rediscache = { version = "0.2.0", extras = ["fakeredis"], optional = true } +statsd = "3.3.0" + +[tool.poetry.extras] +async-rediscache = ["async-rediscache"] [tool.poetry.dev-dependencies] flake8 = "4.0.1" -- cgit v1.2.3 From 3e7142447a81cf216e4c3f1237f2ea91aa161dcf Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 27 Mar 2022 17:30:36 +0100 Subject: Remove inappropriate abstractmethod tags --- botcore/_bot.py | 3 --- 1 file changed, 3 deletions(-) (limited to 'botcore/_bot.py') diff --git a/botcore/_bot.py b/botcore/_bot.py index d21ed6b9..82abbdc0 100644 --- a/botcore/_bot.py +++ b/botcore/_bot.py @@ -2,7 +2,6 @@ import asyncio import socket import types import warnings -from abc import abstractmethod from contextlib import suppress from typing import Optional @@ -194,7 +193,6 @@ class BotBase(commands.Bot): self._guild_available.set() - @abstractmethod async def log_to_dev_log(self, message: str) -> None: """Log the given message to #dev-log.""" ... @@ -227,7 +225,6 @@ class BotBase(commands.Bot): except Exception as e: raise StartupError(e) - @abstractmethod async def ping_services() -> None: """Ping all required services on setup to ensure they are up before starting.""" ... -- cgit v1.2.3 From 5a02b3bc6b8bba269b824622046cf76d3389842d Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 31 Mar 2022 17:07:17 +0100 Subject: Remove BotBase.closing_tasks Doing a naive git log --grep closing_tasks on the bot repo I found these two places it was used: https://github.com/python-discord/bot/commit/429cc865309242f0cf37147f9c3f05036972eb8c - Reddit cog to revoke the access token on unload, which has since been moved to lance (without this feature). https://github.com/python-discord/bot/commit/f4004d814c1babfb5906afb8cd9944ceef90a2a3 - Silence cog, which has been removed since. Since this list of tasks to close when closing the Discord connection is no longer used, it has been removed from BotBase. --- botcore/_bot.py | 7 ------- 1 file changed, 7 deletions(-) (limited to 'botcore/_bot.py') diff --git a/botcore/_bot.py b/botcore/_bot.py index 82abbdc0..55923dd0 100644 --- a/botcore/_bot.py +++ b/botcore/_bot.py @@ -109,9 +109,6 @@ class BotBase(commands.Bot): attempt + 1 ) - # All tasks that need to block closing until finished - self.closing_tasks: list[asyncio.Task] = [] - async def load_extensions(self, module: types.ModuleType) -> None: """Load all the extensions within the given module and save them to ``self.all_extensions``.""" self.all_extensions = walk_extensions(module) @@ -240,10 +237,6 @@ class BotBase(commands.Bot): with suppress(Exception): await self.remove_cog(cog) - # Wait until all tasks that have to be completed before bot is closing is done - log.trace("Waiting for tasks before closing.") - await asyncio.gather(*self.closing_tasks) - # Now actually do full close of bot await super().close() -- cgit v1.2.3 From 7d00aec297e0e65653632037a3e497bff787bfb9 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Sat, 2 Apr 2022 20:05:53 +0100 Subject: Use imperative mood in docstrings Co-authored-by: Mark <1515135+MarkKoz@users.noreply.github.com> --- botcore/_bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'botcore/_bot.py') diff --git a/botcore/_bot.py b/botcore/_bot.py index 55923dd0..a43bb344 100644 --- a/botcore/_bot.py +++ b/botcore/_bot.py @@ -138,7 +138,7 @@ class BotBase(commands.Bot): self.all_commands.pop(alias, None) async def add_cog(self, cog: commands.Cog) -> None: - """Adds the given ``cog`` to the bot and logs the operation.""" + """Add the given ``cog`` to the bot and log the operation.""" await super().add_cog(cog) log.info(f"Cog loaded: {cog.qualified_name}") -- cgit v1.2.3