aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Mark <[email protected]>2022-06-08 16:10:18 -0700
committerGravatar Mark <[email protected]>2022-06-08 16:10:18 -0700
commitcfe69d1d76e857cab5a8df98208671162cdd3f3b (patch)
treeaf03758d97deff81f0454777424f1eb9191c3fc9
parentImprove nomination message consistency and include user mentions (diff)
parentchore: Remove allowed_strings in favour of Literal (diff)
Merge main into nominations-tagging
-rw-r--r--.github/CODEOWNERS22
-rw-r--r--.github/workflows/lint-test.yml4
-rw-r--r--README.md1
-rw-r--r--bot/__init__.py17
-rw-r--r--bot/__main__.py72
-rw-r--r--bot/api.py102
-rw-r--r--bot/async_stats.py41
-rw-r--r--bot/bot.py302
-rw-r--r--bot/constants.py11
-rw-r--r--bot/converters.py48
-rw-r--r--bot/decorators.py43
-rw-r--r--bot/exts/backend/branding/__init__.py4
-rw-r--r--bot/exts/backend/branding/_cog.py137
-rw-r--r--bot/exts/backend/branding/_repository.py11
-rw-r--r--bot/exts/backend/config_verifier.py8
-rw-r--r--bot/exts/backend/error_handler.py40
-rw-r--r--bot/exts/backend/logging.py6
-rw-r--r--bot/exts/backend/sync/__init__.py4
-rw-r--r--bot/exts/backend/sync/_cog.py24
-rw-r--r--bot/exts/backend/sync/_syncers.py15
-rw-r--r--bot/exts/events/code_jams/__init__.py4
-rw-r--r--bot/exts/events/code_jams/_cog.py13
-rw-r--r--bot/exts/filters/antimalware.py14
-rw-r--r--bot/exts/filters/antispam.py16
-rw-r--r--bot/exts/filters/filter_lists.py23
-rw-r--r--bot/exts/filters/filtering.py71
-rw-r--r--bot/exts/filters/security.py4
-rw-r--r--bot/exts/filters/token_remover.py9
-rw-r--r--bot/exts/filters/webhook_remover.py4
-rw-r--r--bot/exts/fun/duck_pond.py28
-rw-r--r--bot/exts/fun/off_topic_names.py76
-rw-r--r--bot/exts/help_channels/__init__.py4
-rw-r--r--bot/exts/help_channels/_caches.py4
-rw-r--r--bot/exts/help_channels/_channel.py39
-rw-r--r--bot/exts/help_channels/_cog.py112
-rw-r--r--bot/exts/help_channels/_message.py149
-rw-r--r--bot/exts/info/code_snippets.py7
-rw-r--r--bot/exts/info/codeblock/__init__.py4
-rw-r--r--bot/exts/info/codeblock/_cog.py3
-rw-r--r--bot/exts/info/doc/__init__.py4
-rw-r--r--bot/exts/info/doc/_batch_parser.py2
-rw-r--r--bot/exts/info/doc/_cog.py29
-rw-r--r--bot/exts/info/doc/_html.py3
-rw-r--r--bot/exts/info/doc/_parsing.py3
-rw-r--r--bot/exts/info/doc/_redis_cache.py40
-rw-r--r--bot/exts/info/help.py32
-rw-r--r--bot/exts/info/information.py41
-rw-r--r--bot/exts/info/pep.py14
-rw-r--r--bot/exts/info/pypi.py4
-rw-r--r--bot/exts/info/python_news.py15
-rw-r--r--bot/exts/info/resources.py70
-rw-r--r--bot/exts/info/source.py4
-rw-r--r--bot/exts/info/stats.py6
-rw-r--r--bot/exts/info/subscribe.py15
-rw-r--r--bot/exts/info/tags.py13
-rw-r--r--bot/exts/moderation/clean.py295
-rw-r--r--bot/exts/moderation/defcon.py61
-rw-r--r--bot/exts/moderation/dm_relay.py20
-rw-r--r--bot/exts/moderation/incidents.py11
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py46
-rw-r--r--bot/exts/moderation/infraction/_utils.py64
-rw-r--r--bot/exts/moderation/infraction/infractions.py178
-rw-r--r--bot/exts/moderation/infraction/management.py82
-rw-r--r--bot/exts/moderation/infraction/superstarify.py36
-rw-r--r--bot/exts/moderation/metabase.py51
-rw-r--r--bot/exts/moderation/modlog.py78
-rw-r--r--bot/exts/moderation/modpings.py49
-rw-r--r--bot/exts/moderation/silence.py27
-rw-r--r--bot/exts/moderation/slowmode.py30
-rw-r--r--bot/exts/moderation/stream.py30
-rw-r--r--bot/exts/moderation/verification.py4
-rw-r--r--bot/exts/moderation/voice_gate.py28
-rw-r--r--bot/exts/moderation/watchchannels/_watchchannel.py16
-rw-r--r--bot/exts/moderation/watchchannels/bigbrother.py6
-rw-r--r--bot/exts/recruitment/talentpool/__init__.py4
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py21
-rw-r--r--bot/exts/recruitment/talentpool/_review.py23
-rw-r--r--bot/exts/utils/bot.py28
-rw-r--r--bot/exts/utils/extensions.py29
-rw-r--r--bot/exts/utils/internal.py16
-rw-r--r--bot/exts/utils/ping.py4
-rw-r--r--bot/exts/utils/reminders.py41
-rw-r--r--bot/exts/utils/snekbox.py350
-rw-r--r--bot/exts/utils/thread_bumper.py158
-rw-r--r--bot/exts/utils/utils.py11
-rw-r--r--bot/monkey_patches.py75
-rw-r--r--bot/resources/tags/contribute.md2
-rw-r--r--bot/resources/tags/dictcomps.md2
-rw-r--r--bot/resources/tags/docstring.md2
-rw-r--r--bot/resources/tags/enumerate.md2
-rw-r--r--bot/resources/tags/indent.md6
-rw-r--r--bot/resources/tags/intents.md2
-rw-r--r--bot/resources/tags/off-topic-names.md10
-rw-r--r--bot/resources/tags/off-topic.md10
-rw-r--r--bot/resources/tags/or-gotcha.md1
-rw-r--r--bot/resources/tags/ot.md3
-rw-r--r--bot/resources/tags/pathlib.md2
-rw-r--r--bot/resources/tags/pep8.md2
-rw-r--r--bot/resources/tags/positional-keyword.md6
-rw-r--r--bot/resources/tags/quotes.md4
-rw-r--r--bot/resources/tags/regex.md15
-rw-r--r--bot/resources/tags/resources.md6
-rw-r--r--bot/resources/tags/sql-fstring.md2
-rw-r--r--bot/resources/tags/star-imports.md2
-rw-r--r--bot/resources/tags/strip-gotcha.md17
-rw-r--r--bot/resources/tags/traceback.md21
-rw-r--r--bot/resources/tags/type-hint.md19
-rw-r--r--bot/resources/tags/with.md2
-rw-r--r--bot/utils/__init__.py12
-rw-r--r--bot/utils/extensions.py34
-rw-r--r--bot/utils/messages.py2
-rw-r--r--bot/utils/regex.py15
-rw-r--r--bot/utils/scheduling.py192
-rw-r--r--bot/utils/services.py34
-rw-r--r--bot/utils/time.py274
-rw-r--r--config-default.yml19
-rw-r--r--docker-compose.yml1
-rw-r--r--poetry.lock1863
-rw-r--r--pyproject.toml85
-rw-r--r--tests/bot/exts/backend/sync/test_base.py3
-rw-r--r--tests/bot/exts/backend/sync/test_cog.py30
-rw-r--r--tests/bot/exts/backend/test_error_handler.py19
-rw-r--r--tests/bot/exts/events/test_code_jams.py8
-rw-r--r--tests/bot/exts/filters/test_antimalware.py8
-rw-r--r--tests/bot/exts/filters/test_filtering.py2
-rw-r--r--tests/bot/exts/filters/test_security.py11
-rw-r--r--tests/bot/exts/filters/test_token_remover.py8
-rw-r--r--tests/bot/exts/info/test_help.py1
-rw-r--r--tests/bot/exts/info/test_information.py76
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py175
-rw-r--r--tests/bot/exts/moderation/infraction/test_utils.py38
-rw-r--r--tests/bot/exts/moderation/test_clean.py104
-rw-r--r--tests/bot/exts/moderation/test_silence.py40
-rw-r--r--tests/bot/exts/utils/test_snekbox.py197
-rw-r--r--tests/bot/test_api.py66
-rw-r--r--tests/bot/utils/test_services.py27
-rw-r--r--tests/bot/utils/test_time.py47
-rw-r--r--tests/helpers.py14
-rw-r--r--tests/test_helpers.py2
-rw-r--r--tox.ini2
140 files changed, 3996 insertions, 3214 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 6dfe7e859..0bc2bb793 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -4,10 +4,10 @@
**/bot/exts/moderation/*silence.py @MarkKoz
bot/exts/info/codeblock/** @MarkKoz
bot/exts/utils/extensions.py @MarkKoz
-bot/exts/utils/snekbox.py @MarkKoz @Akarys42 @jb3
-bot/exts/help_channels/** @MarkKoz @Akarys42
-bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 @ks129 @jb3
-bot/exts/info/** @Akarys42 @Den4200 @jb3
+bot/exts/utils/snekbox.py @MarkKoz @jb3
+bot/exts/help_channels/** @MarkKoz
+bot/exts/moderation/** @mbaruh @Den4200 @ks129 @jb3
+bot/exts/info/** @Den4200 @jb3
bot/exts/info/information.py @mbaruh @jb3
bot/exts/filters/** @mbaruh @jb3
bot/exts/fun/** @ks129
@@ -18,25 +18,17 @@ bot/exts/recruitment/** @wookie184
bot/rules/** @mbaruh
# Utils
-bot/utils/extensions.py @MarkKoz
bot/utils/function.py @MarkKoz
bot/utils/lock.py @MarkKoz
-bot/utils/regex.py @Akarys42
-bot/utils/scheduling.py @MarkKoz
# Tests
tests/_autospec.py @MarkKoz
tests/bot/exts/test_cogs.py @MarkKoz
-tests/** @Akarys42
# CI & Docker
-.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ @Den4200 @jb3
-Dockerfile @MarkKoz @Akarys42 @Den4200 @jb3
-docker-compose.yml @MarkKoz @Akarys42 @Den4200 @jb3
-
-# Tools
-poetry.lock @Akarys42
-pyproject.toml @Akarys42
+.github/workflows/** @MarkKoz @SebastiaanZ @Den4200 @jb3
+Dockerfile @MarkKoz @Den4200 @jb3
+docker-compose.yml @MarkKoz @Den4200 @jb3
# Statistics
bot/async_stats.py @jb3
diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml
index f2c9dfb6c..57cc544d9 100644
--- a/.github/workflows/lint-test.yml
+++ b/.github/workflows/lint-test.yml
@@ -46,6 +46,10 @@ jobs:
PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base
PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache
+ # See https://github.com/pre-commit/pre-commit/issues/2178#issuecomment-1002163763
+ # for why we set this.
+ SETUPTOOLS_USE_DISTUTILS: stdlib
+
steps:
- name: Add custom PYTHONUSERBASE to PATH
run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH
diff --git a/README.md b/README.md
index 9df905dc8..06df4fd9a 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,6 @@
[![Lint & Test][1]][2]
[![Build][3]][4]
[![Deploy][5]][6]
-[![Coverage Status](https://coveralls.io/repos/github/python-discord/bot/badge.svg)](https://coveralls.io/github/python-discord/bot)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities
diff --git a/bot/__init__.py b/bot/__init__.py
index 17d99105a..c652897be 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -1,11 +1,10 @@
import asyncio
import os
-from functools import partial, partialmethod
from typing import TYPE_CHECKING
-from discord.ext import commands
+from botcore.utils import apply_monkey_patches
-from bot import log, monkey_patches
+from bot import log
if TYPE_CHECKING:
from bot.bot import Bot
@@ -16,16 +15,6 @@ log.setup()
if os.name == "nt":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
-monkey_patches.patch_typing()
-
-# This patches any convertors that use PartialMessage, but not the PartialMessageConverter itself
-# as library objects are made by this mapping.
-# https://github.com/Rapptz/discord.py/blob/1a4e73d59932cdbe7bf2c281f25e32529fc7ae1f/discord/ext/commands/converter.py#L984-L1004
-commands.converter.PartialMessageConverter = monkey_patches.FixedPartialMessageConverter
-
-# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases.
-# Must be patched before any cogs are added.
-commands.command = partial(commands.command, cls=monkey_patches.Command)
-commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=monkey_patches.Command)
+apply_monkey_patches()
instance: "Bot" = None # Global Bot instance.
diff --git a/bot/__main__.py b/bot/__main__.py
index 0d3fce180..fc4475068 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -1,16 +1,80 @@
+import asyncio
+
import aiohttp
+import discord
+from async_rediscache import RedisSession
+from botcore import StartupError
+from botcore.site_api import APIClient
+from discord.ext import commands
import bot
from bot import constants
-from bot.bot import Bot, StartupError
+from bot.bot import Bot
from bot.log import get_logger, setup_sentry
setup_sentry()
+LOCALHOST = "127.0.0.1"
+
+
+async def _create_redis_session() -> RedisSession:
+ """Create and connect to a redis session."""
+ redis_session = RedisSession(
+ address=(constants.Redis.host, constants.Redis.port),
+ password=constants.Redis.password,
+ minsize=1,
+ maxsize=20,
+ use_fakeredis=constants.Redis.use_fakeredis,
+ global_namespace="bot",
+ )
+ try:
+ await redis_session.connect()
+ except OSError as e:
+ raise StartupError(e)
+ return redis_session
+
+
+async def main() -> None:
+ """Entry async method for starting the bot."""
+ statsd_url = constants.Stats.statsd_host
+ if constants.DEBUG_MODE:
+ # Since statsd is UDP, there are no errors for sending to a down port.
+ # For this reason, setting the statsd host to 127.0.0.1 for development
+ # will effectively disable stats.
+ statsd_url = LOCALHOST
+
+ allowed_roles = list({discord.Object(id_) for id_ in constants.MODERATION_ROLES})
+ intents = discord.Intents.all()
+ intents.presences = False
+ intents.dm_typing = False
+ intents.dm_reactions = False
+ intents.invites = False
+ intents.webhooks = False
+ intents.integrations = False
+
+ async with aiohttp.ClientSession() as session:
+ bot.instance = Bot(
+ guild_id=constants.Guild.id,
+ http_session=session,
+ redis_session=await _create_redis_session(),
+ statsd_url=statsd_url,
+ command_prefix=commands.when_mentioned_or(constants.Bot.prefix),
+ activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"),
+ case_insensitive=True,
+ max_messages=10_000,
+ allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles),
+ intents=intents,
+ allowed_roles=list({discord.Object(id_) for id_ in constants.MODERATION_ROLES}),
+ api_client=APIClient(
+ site_api_url=f"{constants.URLs.site_api_schema}{constants.URLs.site_api}",
+ site_api_token=constants.Keys.site_api,
+ ),
+ )
+ async with bot.instance as _bot:
+ await _bot.start(constants.Bot.token)
+
try:
- bot.instance = Bot.create()
- bot.instance.load_extensions()
- bot.instance.run(constants.Bot.token)
+ asyncio.run(main())
except StartupError as e:
message = "Unknown Startup Error Occurred."
if isinstance(e.exception, (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError)):
diff --git a/bot/api.py b/bot/api.py
deleted file mode 100644
index 856f7c865..000000000
--- a/bot/api.py
+++ /dev/null
@@ -1,102 +0,0 @@
-import asyncio
-from typing import Optional
-from urllib.parse import quote as quote_url
-
-import aiohttp
-
-from bot.log import get_logger
-
-from .constants import Keys, URLs
-
-log = get_logger(__name__)
-
-
-class ResponseCodeError(ValueError):
- """Raised when a non-OK HTTP response is received."""
-
- def __init__(
- self,
- response: aiohttp.ClientResponse,
- response_json: Optional[dict] = None,
- response_text: str = ""
- ):
- self.status = response.status
- self.response_json = response_json or {}
- self.response_text = response_text
- self.response = response
-
- def __str__(self):
- response = self.response_json if self.response_json else self.response_text
- return f"Status: {self.status} Response: {response}"
-
-
-class APIClient:
- """Django Site API wrapper."""
-
- # These are class attributes so they can be seen when being mocked for tests.
- # See commit 22a55534ef13990815a6f69d361e2a12693075d5 for details.
- session: Optional[aiohttp.ClientSession] = None
- loop: asyncio.AbstractEventLoop = None
-
- def __init__(self, **session_kwargs):
- auth_headers = {
- 'Authorization': f"Token {Keys.site_api}"
- }
-
- if 'headers' in session_kwargs:
- session_kwargs['headers'].update(auth_headers)
- else:
- session_kwargs['headers'] = auth_headers
-
- # aiohttp will complain if APIClient gets instantiated outside a coroutine. Thankfully, we
- # don't and shouldn't need to do that, so we can avoid scheduling a task to create it.
- self.session = aiohttp.ClientSession(**session_kwargs)
-
- @staticmethod
- def _url_for(endpoint: str) -> str:
- return f"{URLs.site_api_schema}{URLs.site_api}/{quote_url(endpoint)}"
-
- async def close(self) -> None:
- """Close the aiohttp session."""
- await self.session.close()
-
- async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None:
- """Raise ResponseCodeError for non-OK response if an exception should be raised."""
- if should_raise and response.status >= 400:
- try:
- response_json = await response.json()
- raise ResponseCodeError(response=response, response_json=response_json)
- except aiohttp.ContentTypeError:
- response_text = await response.text()
- raise ResponseCodeError(response=response, response_text=response_text)
-
- async def request(self, method: str, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict:
- """Send an HTTP request to the site API and return the JSON response."""
- async with self.session.request(method.upper(), self._url_for(endpoint), **kwargs) as resp:
- await self.maybe_raise_for_status(resp, raise_for_status)
- return await resp.json()
-
- async def get(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict:
- """Site API GET."""
- return await self.request("GET", endpoint, raise_for_status=raise_for_status, **kwargs)
-
- async def patch(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict:
- """Site API PATCH."""
- return await self.request("PATCH", endpoint, raise_for_status=raise_for_status, **kwargs)
-
- async def post(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict:
- """Site API POST."""
- return await self.request("POST", endpoint, raise_for_status=raise_for_status, **kwargs)
-
- async def put(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict:
- """Site API PUT."""
- return await self.request("PUT", endpoint, raise_for_status=raise_for_status, **kwargs)
-
- async def delete(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> Optional[dict]:
- """Site API DELETE."""
- async with self.session.delete(self._url_for(endpoint), **kwargs) as resp:
- if resp.status == 204:
- return None
-
- await self.maybe_raise_for_status(resp, raise_for_status)
- return await resp.json()
diff --git a/bot/async_stats.py b/bot/async_stats.py
deleted file mode 100644
index 2af832e5b..000000000
--- a/bot/async_stats.py
+++ /dev/null
@@ -1,41 +0,0 @@
-import asyncio
-import socket
-
-from statsd.client.base import StatsClientBase
-
-from bot.utils import scheduling
-
-
-class AsyncStatsClient(StatsClientBase):
- """An async transport method for statsd communication."""
-
- def __init__(
- self,
- loop: asyncio.AbstractEventLoop,
- host: str = 'localhost',
- port: int = 8125,
- prefix: str = None
- ):
- """Create a new client."""
- family, _, _, _, addr = socket.getaddrinfo(
- host, port, socket.AF_INET, socket.SOCK_DGRAM)[0]
- self._addr = addr
- self._prefix = prefix
- self._loop = loop
- self._transport = None
-
- async def create_socket(self) -> None:
- """Use the loop.create_datagram_endpoint method to create a socket."""
- self._transport, _ = await self._loop.create_datagram_endpoint(
- asyncio.DatagramProtocol,
- family=socket.AF_INET,
- remote_addr=self._addr
- )
-
- def _send(self, data: str) -> None:
- """Start an async task to send data to statsd."""
- scheduling.create_task(self._async_send(data), event_loop=self._loop)
-
- async def _async_send(self, data: str) -> None:
- """Send data to the statsd server using the async transport."""
- self._transport.sendto(data.encode('ascii'), self._addr)
diff --git a/bot/bot.py b/bot/bot.py
index 94783a466..aff07cd32 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -1,22 +1,15 @@
import asyncio
-import socket
-import warnings
from collections import defaultdict
-from contextlib import suppress
-from typing import Dict, List, Optional
import aiohttp
-import discord
-from async_rediscache import RedisSession
-from discord.ext import commands
+from botcore import BotBase
+from botcore.utils import scheduling
from sentry_sdk import push_scope
-from bot import api, constants
-from bot.async_stats import AsyncStatsClient
+from bot import constants, exts
from bot.log import get_logger
log = get_logger('bot')
-LOCALHOST = "127.0.0.1"
class StartupError(Exception):
@@ -27,68 +20,15 @@ class StartupError(Exception):
self.exception = base
-class Bot(commands.Bot):
- """A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client."""
+class Bot(BotBase):
+ """A subclass of `botcore.BotBase` that implements bot-specific functions."""
- def __init__(self, *args, redis_session: RedisSession, **kwargs):
- if "connector" in kwargs:
- warnings.warn(
- "If login() is called (or the bot is started), the connector will be overwritten "
- "with an internal one"
- )
+ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.http_session: Optional[aiohttp.ClientSession] = None
- self.redis_session = redis_session
- self.api_client: Optional[api.APIClient] = None
self.filter_list_cache = defaultdict(dict)
- self._connector = None
- self._resolver = None
- self._statsd_timerhandle: asyncio.TimerHandle = None
- self._guild_available = asyncio.Event()
-
- statsd_url = constants.Stats.statsd_host
-
- if constants.DEBUG_MODE:
- # Since statsd is UDP, there are no errors for sending to a down port.
- # For this reason, setting the statsd host to 127.0.0.1 for development
- # will effectively disable stats.
- statsd_url = LOCALHOST
-
- self.stats = AsyncStatsClient(self.loop, LOCALHOST)
- self._connect_statsd(statsd_url)
-
- def _connect_statsd(self, statsd_url: str, 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(self.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 = self.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 cache_filter_list_data(self) -> None:
- """Cache all the data in the FilterList on the site."""
- full_cache = await self.api_client.get('bot/filter-lists')
-
- for item in full_cache:
- self.insert_item_into_filter_list_cache(item)
-
async def ping_services(self) -> None:
"""A helper to make sure all the services the bot relies on are available on startup."""
# Connect Site/API
@@ -105,112 +45,7 @@ class Bot(commands.Bot):
raise
await asyncio.sleep(constants.URLs.connect_cooldown)
- @classmethod
- def create(cls) -> "Bot":
- """Create and return an instance of a Bot."""
- loop = asyncio.get_event_loop()
- allowed_roles = list({discord.Object(id_) for id_ in constants.MODERATION_ROLES})
-
- intents = discord.Intents.all()
- intents.presences = False
- intents.dm_typing = False
- intents.dm_reactions = False
- intents.invites = False
- intents.webhooks = False
- intents.integrations = False
-
- return cls(
- redis_session=_create_redis_session(loop),
- loop=loop,
- command_prefix=commands.when_mentioned_or(constants.Bot.prefix),
- activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"),
- case_insensitive=True,
- max_messages=10_000,
- allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles),
- intents=intents,
- )
-
- def load_extensions(self) -> None:
- """Load all enabled extensions."""
- # Must be done here to avoid a circular import.
- from bot.utils.extensions import EXTENSIONS
-
- extensions = set(EXTENSIONS) # Create a mutable copy.
- if not constants.HelpChannels.enable:
- extensions.remove("bot.exts.help_channels")
-
- for extension in extensions:
- self.load_extension(extension)
-
- def add_cog(self, cog: commands.Cog) -> None:
- """Adds a "cog" to the bot and logs the operation."""
- 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
-
- 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 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):
- self.unload_extension(ext)
-
- for cog in list(self.cogs):
- with suppress(Exception):
- 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()
-
- def insert_item_into_filter_list_cache(self, item: Dict[str, str]) -> None:
+ def insert_item_into_filter_list_cache(self, item: dict[str, str]) -> None:
"""Add an item to the bots filter_list_cache."""
type_ = item["type"]
allowed = item["allowed"]
@@ -223,81 +58,23 @@ class Bot(commands.Bot):
"updated_at": item["updated_at"],
}
- async def login(self, *args, **kwargs) -> None:
- """Re-create the connector and set up sessions before logging into Discord."""
- # Use asyncio for DNS resolution instead of threads so threads aren't spammed.
- self._resolver = aiohttp.AsyncResolver()
-
- # Use AF_INET as its socket family to prevent HTTPS related problems both locally
- # and in production.
- self._connector = aiohttp.TCPConnector(
- resolver=self._resolver,
- family=socket.AF_INET,
- )
-
- # Client.login() will call HTTPClient.static_login() which will create a session using
- # this connector attribute.
- self.http.connector = self._connector
-
- self.http_session = aiohttp.ClientSession(connector=self._connector)
- self.api_client = api.APIClient(connector=self._connector)
+ async def cache_filter_list_data(self) -> None:
+ """Cache all the data in the FilterList on the site."""
+ full_cache = await self.api_client.get('bot/filter-lists')
- if self.redis_session.closed:
- # If the RedisSession was somehow closed, we try to reconnect it
- # here. Normally, this shouldn't happen.
- await self.redis_session.connect()
+ for item in full_cache:
+ self.insert_item_into_filter_list_cache(item)
- try:
- await self.ping_services()
- except Exception as e:
- raise StartupError(e)
+ async def setup_hook(self) -> None:
+ """Default async initialisation method for discord.py."""
+ await super().setup_hook()
# Build the FilterList cache
await self.cache_filter_list_data()
- await self.stats.create_socket()
- await super().login(*args, **kwargs)
-
- async def on_guild_available(self, guild: discord.Guild) -> None:
- """
- Set the internal guild available event when constants.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.
- """
- if guild.id != constants.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!"
- log.warning(msg)
-
- try:
- webhook = await self.fetch_webhook(constants.Webhooks.dev_log)
- except discord.HTTPException as e:
- log.error(f"Failed to fetch webhook to send empty cache warning: status {e.status}")
- else:
- await webhook.send(f"<@&{constants.Roles.admin}> {msg}")
-
- return
-
- self._guild_available.set()
-
- async def on_guild_unavailable(self, guild: discord.Guild) -> None:
- """Clear the internal guild available event when constants.Guild.id becomes unavailable."""
- if guild.id != constants.Guild.id:
- return
-
- self._guild_available.clear()
-
- async def wait_until_guild_available(self) -> None:
- """
- Wait until the constants.Guild.id guild 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()
+ # This is not awaited to avoid a deadlock with any cogs that have
+ # wait_until_guild_available in their cog_load method.
+ scheduling.create_task(self.load_extensions(exts))
async def on_error(self, event: str, *args, **kwargs) -> None:
"""Log errors raised in event listeners rather than printing them to stderr."""
@@ -309,46 +86,3 @@ class Bot(commands.Bot):
scope.set_extra("kwargs", kwargs)
log.exception(f"Unhandled exception in {event}.")
-
- 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)
-
-
-def _create_redis_session(loop: asyncio.AbstractEventLoop) -> RedisSession:
- """
- Create and connect to a redis session.
-
- Ensure the connection is established before returning to prevent race conditions.
- `loop` is the event loop on which to connect. The Bot should use this same event loop.
- """
- redis_session = RedisSession(
- address=(constants.Redis.host, constants.Redis.port),
- password=constants.Redis.password,
- minsize=1,
- maxsize=20,
- use_fakeredis=constants.Redis.use_fakeredis,
- global_namespace="bot",
- )
- try:
- loop.run_until_complete(redis_session.connect())
- except OSError as e:
- raise StartupError(e)
- return redis_session
diff --git a/bot/constants.py b/bot/constants.py
index 078ab6912..4531b547d 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -429,10 +429,8 @@ class Channels(metaclass=YAMLGetter):
off_topic_1: int
off_topic_2: int
- black_formatter: int
-
bot_commands: int
- discord_py: int
+ discord_bots: int
esoteric: int
voice_gate: int
code_jam_planning: int
@@ -445,6 +443,7 @@ class Channels(metaclass=YAMLGetter):
incidents_archive: int
mod_alerts: int
mod_meta: int
+ mods: int
nominations: int
nomination_voting: int
organisation: int
@@ -619,10 +618,12 @@ class HelpChannels(metaclass=YAMLGetter):
max_available: int
max_total_channels: int
name_prefix: str
- notify: bool
notify_channel: int
notify_minutes: int
- notify_roles: List[int]
+ notify_none_remaining: bool
+ notify_none_remaining_roles: List[int]
+ notify_running_low: bool
+ notify_running_low_threshold: int
class RedirectOutput(metaclass=YAMLGetter):
diff --git a/bot/converters.py b/bot/converters.py
index 559e759e1..8a140e0c2 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -6,23 +6,22 @@ from datetime import datetime, timezone
from ssl import CertificateError
import dateutil.parser
-import dateutil.tz
import discord
from aiohttp import ClientConnectorError
+from botcore.site_api import ResponseCodeError
+from botcore.utils import unqualify
+from botcore.utils.regex import DISCORD_INVITE
from dateutil.relativedelta import relativedelta
from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter
from discord.utils import escape_markdown, snowflake_time
-from bot import exts
-from bot.api import ResponseCodeError
+from bot import exts, instance as bot_instance
from bot.constants import URLs
from bot.errors import InvalidInfraction
from bot.exts.info.doc import _inventory_parser
from bot.exts.info.tags import TagIdentifier
from bot.log import get_logger
-from bot.utils.extensions import EXTENSIONS, unqualify
-from bot.utils.regex import INVITE_RE
-from bot.utils.time import parse_duration_string
+from bot.utils import time
if t.TYPE_CHECKING:
from bot.exts.info.source import SourceType
@@ -33,25 +32,6 @@ DISCORD_EPOCH_DT = snowflake_time(0)
RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$")
-def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], str]:
- """
- Return a converter which only allows arguments equal to one of the given values.
-
- Unless preserve_case is True, the argument is converted to lowercase. All values are then
- expected to have already been given in lowercase too.
- """
- def converter(arg: str) -> str:
- if not preserve_case:
- arg = arg.lower()
-
- if arg not in values:
- raise BadArgument(f"Only the following values are allowed:\n```{', '.join(values)}```")
- else:
- return arg
-
- return converter
-
-
class ValidDiscordServerInvite(Converter):
"""
A converter that validates whether a given string is a valid Discord server invite.
@@ -72,7 +52,7 @@ class ValidDiscordServerInvite(Converter):
async def convert(self, ctx: Context, server_invite: str) -> dict:
"""Check whether the string is a valid Discord server invite."""
- invite_code = INVITE_RE.match(server_invite)
+ invite_code = DISCORD_INVITE.match(server_invite)
if invite_code:
response = await ctx.bot.http_session.get(
f"{URLs.discord_invite_api}/{invite_code.group('invite')}"
@@ -151,13 +131,13 @@ class Extension(Converter):
argument = argument.lower()
- if argument in EXTENSIONS:
+ if argument in bot_instance.all_extensions:
return argument
- elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS:
+ elif (qualified_arg := f"{exts.__name__}.{argument}") in bot_instance.all_extensions:
return qualified_arg
matches = []
- for ext in EXTENSIONS:
+ for ext in bot_instance.all_extensions:
if argument == unqualify(ext):
matches.append(ext)
@@ -338,7 +318,7 @@ class DurationDelta(Converter):
The units need to be provided in descending order of magnitude.
"""
- if not (delta := parse_duration_string(duration)):
+ if not (delta := time.parse_duration_string(duration)):
raise BadArgument(f"`{duration}` is not a valid duration string.")
return delta
@@ -383,8 +363,8 @@ class Age(DurationDelta):
class OffTopicName(Converter):
"""A converter that ensures an added off-topic name is valid."""
- ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-<>"
- TRANSLATED_CHARACTERS = "𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-<>"
+ ALLOWED_CHARACTERS = r"ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-<>\/"
+ TRANSLATED_CHARACTERS = "𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-<>⧹⧸"
@classmethod
def translate_name(cls, name: str, *, from_unicode: bool = True) -> str:
@@ -454,9 +434,9 @@ class ISODateTime(Converter):
raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string")
if dt.tzinfo:
- dt = dt.astimezone(dateutil.tz.UTC)
+ dt = dt.astimezone(timezone.utc)
else: # Without a timezone, assume it represents UTC.
- dt = dt.replace(tzinfo=dateutil.tz.UTC)
+ dt = dt.replace(tzinfo=timezone.utc)
return dt
diff --git a/bot/decorators.py b/bot/decorators.py
index 048a2a09a..466770c3a 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -4,13 +4,15 @@ import types
import typing as t
from contextlib import suppress
+import arrow
+from botcore.utils import scheduling
from discord import Member, NotFound
from discord.ext import commands
from discord.ext.commands import Cog, Context
from bot.constants import Channels, DEBUG_MODE, RedirectOutput
from bot.log import get_logger
-from bot.utils import function, scheduling
+from bot.utils import function
from bot.utils.checks import ContextCheckFailure, in_whitelist_check
from bot.utils.function import command_wraps
@@ -188,7 +190,7 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable:
"""
def decorator(func: types.FunctionType) -> types.FunctionType:
@command_wraps(func)
- async def wrapper(*args, **kwargs) -> None:
+ async def wrapper(*args, **kwargs) -> t.Any:
log.trace(f"{func.__name__}: respect role hierarchy decorator called")
bound_args = function.get_bound_args(func, args, kwargs)
@@ -196,8 +198,7 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable:
if not isinstance(target, Member):
log.trace("The target is not a discord.Member; skipping role hierarchy check.")
- await func(*args, **kwargs)
- return
+ return await func(*args, **kwargs)
ctx = function.get_arg_value(1, bound_args)
cmd = ctx.command.name
@@ -214,7 +215,7 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable:
)
else:
log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func")
- await func(*args, **kwargs)
+ return await func(*args, **kwargs)
return wrapper
return decorator
@@ -237,3 +238,35 @@ def mock_in_debug(return_value: t.Any) -> t.Callable:
return await func(*args, **kwargs)
return wrapped
return decorator
+
+
+def ensure_future_timestamp(timestamp_arg: function.Argument) -> t.Callable:
+ """
+ Ensure the timestamp argument is in the future.
+
+ If the condition fails, send a warning to the invoking context.
+
+ `timestamp_arg` is the keyword name or position index of the parameter of the decorated command
+ whose value is the target timestamp.
+
+ This decorator must go before (below) the `command` decorator.
+ """
+ def decorator(func: types.FunctionType) -> types.FunctionType:
+ @command_wraps(func)
+ async def wrapper(*args, **kwargs) -> t.Any:
+ bound_args = function.get_bound_args(func, args, kwargs)
+ target = function.get_arg_value(timestamp_arg, bound_args)
+
+ ctx = function.get_arg_value(1, bound_args)
+
+ try:
+ is_future = target > arrow.utcnow()
+ except TypeError:
+ is_future = True
+ if not is_future:
+ await ctx.send(":x: Provided timestamp is in the past.")
+ return
+
+ return await func(*args, **kwargs)
+ return wrapper
+ return decorator
diff --git a/bot/exts/backend/branding/__init__.py b/bot/exts/backend/branding/__init__.py
index 20a747b7f..8460465cb 100644
--- a/bot/exts/backend/branding/__init__.py
+++ b/bot/exts/backend/branding/__init__.py
@@ -2,6 +2,6 @@ from bot.bot import Bot
from bot.exts.backend.branding._cog import Branding
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load Branding cog."""
- bot.add_cog(Branding(bot))
+ await bot.add_cog(Branding(bot))
diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py
index 0c5839a7a..ff2704835 100644
--- a/bot/exts/backend/branding/_cog.py
+++ b/bot/exts/backend/branding/_cog.py
@@ -1,6 +1,7 @@
import asyncio
import contextlib
import random
+import types
import typing as t
from datetime import timedelta
from enum import Enum
@@ -17,7 +18,6 @@ from bot.constants import Branding as BrandingConfig, Channels, Colours, Guild,
from bot.decorators import mock_in_debug
from bot.exts.backend.branding._repository import BrandingRepository, Event, RemoteObject
from bot.log import get_logger
-from bot.utils import scheduling
log = get_logger(__name__)
@@ -105,19 +105,24 @@ class Branding(commands.Cog):
"""
# RedisCache[
- # "daemon_active": bool | If True, daemon starts on start-up. Controlled via commands.
- # "event_path": str | Current event's path in the branding repo.
- # "event_description": str | Current event's Markdown description.
- # "event_duration": str | Current event's human-readable date range.
- # "banner_hash": str | SHA of the currently applied banner.
- # "icons_hash": str | Compound SHA of all icons in current rotation.
- # "last_rotation_timestamp": float | POSIX UTC timestamp.
+ # "daemon_active": bool | If True, daemon starts on start-up. Controlled via commands.
+ # "event_path": str | Current event's path in the branding repo.
+ # "event_description": str | Current event's Markdown description.
+ # "event_duration": str | Current event's human-readable date range.
+ # "banners_hash": str | Compound SHA of all banners in the current rotation.
+ # "icons_hash": str | Compound SHA of all icons in current rotation.
+ # "last_icon_rotation_timestamp": float | POSIX UTC timestamp.
+ # "last_banner_rotation_timestamp": float | POSIX UTC timestamp.
# ]
cache_information = RedisCache()
- # Icons in current rotation. Keys (str) are download URLs, values (int) track the amount of times each
- # icon has been used in the current rotation.
- cache_icons = RedisCache()
+ # Icons and banners in current rotation.
+ # Keys (str) are download URLs, values (int) track the amount of times each
+ # asset has been used in the current rotation.
+ asset_caches = types.MappingProxyType({
+ AssetType.ICON: RedisCache(namespace="Branding.icon_cache"),
+ AssetType.BANNER: RedisCache(namespace="Branding.banner_cache")
+ })
# All available event names & durations. Cached by the daemon nightly; read by the calendar command.
cache_events = RedisCache()
@@ -127,7 +132,9 @@ class Branding(commands.Cog):
self.bot = bot
self.repository = BrandingRepository(bot)
- scheduling.create_task(self.maybe_start_daemon(), event_loop=self.bot.loop) # Start depending on cache.
+ async def cog_load(self) -> None:
+ """Carry out cog asynchronous initialisation."""
+ await self.maybe_start_daemon() # Start depending on cache.
# region: Internal logic & state management
@@ -163,107 +170,92 @@ class Branding(commands.Cog):
log.trace("Asset uploaded successfully.")
return True
- async def apply_banner(self, banner: RemoteObject) -> bool:
+ async def rotate_assets(self, asset_type: AssetType) -> bool:
"""
- Apply `banner` to the guild and cache its hash if successful.
+ Choose and apply the next-up asset in rotation.
- Banners should always be applied via this method to ensure that the last hash is cached.
-
- Return a boolean indicating whether the application was successful.
- """
- success = await self.apply_asset(AssetType.BANNER, banner.download_url)
-
- if success:
- await self.cache_information.set("banner_hash", banner.sha)
-
- return success
-
- async def rotate_icons(self) -> bool:
- """
- Choose and apply the next-up icon in rotation.
-
- We keep track of the amount of times each icon has been used. The values in `cache_icons` can be understood
- to be iteration IDs. When an icon is chosen & applied, we bump its count, pushing it into the next iteration.
+ We keep track of the amount of times each asset has been used. The values in the cache can be understood
+ to be iteration IDs. When an asset is chosen & applied, we bump its count, pushing it into the next iteration.
Once the current iteration (lowest count in the cache) depletes, we move onto the next iteration.
- In the case that there is only 1 icon in the rotation and has already been applied, do nothing.
+ In the case that there is only 1 asset in the rotation and has already been applied, do nothing.
- Return a boolean indicating whether a new icon was applied successfully.
+ Return a boolean indicating whether a new asset was applied successfully.
"""
- log.debug("Rotating icons.")
+ log.debug(f"Rotating {asset_type.value}s.")
- state = await self.cache_icons.to_dict()
- log.trace(f"Total icons in rotation: {len(state)}.")
+ state = await self.asset_caches[asset_type].to_dict()
+ log.trace(f"Total {asset_type.value}s in rotation: {len(state)}.")
if not state: # This would only happen if rotation not initiated, but we can handle gracefully.
- log.warning("Attempted icon rotation with an empty icon cache. This indicates wrong logic.")
+ log.warning(f"Attempted {asset_type.value} rotation with an empty cache. This indicates wrong logic.")
return False
if len(state) == 1 and 1 in state.values():
- log.debug("Aborting icon rotation: only 1 icon is available and has already been applied.")
+ log.debug(f"Aborting {asset_type.value} rotation: only 1 asset is available and has already been applied.")
return False
current_iteration = min(state.values()) # Choose iteration to draw from.
options = [download_url for download_url, times_used in state.items() if times_used == current_iteration]
- log.trace(f"Choosing from {len(options)} icons in iteration {current_iteration}.")
- next_icon = random.choice(options)
+ log.trace(f"Choosing from {len(options)} {asset_type.value}s in iteration {current_iteration}.")
+ next_asset = random.choice(options)
- success = await self.apply_asset(AssetType.ICON, next_icon)
+ success = await self.apply_asset(asset_type, next_asset)
if success:
- await self.cache_icons.increment(next_icon) # Push the icon into the next iteration.
+ await self.asset_caches[asset_type].increment(next_asset) # Push the asset into the next iteration.
timestamp = Arrow.utcnow().timestamp()
- await self.cache_information.set("last_rotation_timestamp", timestamp)
+ await self.cache_information.set(f"last_{asset_type.value}_rotation_timestamp", timestamp)
return success
- async def maybe_rotate_icons(self) -> None:
+ async def maybe_rotate_assets(self, asset_type: AssetType) -> None:
"""
- Call `rotate_icons` if the configured amount of time has passed since last rotation.
+ Call `rotate_assets` if the configured amount of time has passed since last rotation.
We offset the calculated time difference into the future to avoid off-by-a-little-bit errors. Because there
is work to be done before the timestamp is read and written, the next read will likely commence slightly
under 24 hours after the last write.
"""
- log.debug("Checking whether it's time for icons to rotate.")
+ log.debug(f"Checking whether it's time for {asset_type.value}s to rotate.")
- last_rotation_timestamp = await self.cache_information.get("last_rotation_timestamp")
+ last_rotation_timestamp = await self.cache_information.get(f"last_{asset_type.value}_rotation_timestamp")
if last_rotation_timestamp is None: # Maiden case ~ never rotated.
- await self.rotate_icons()
+ await self.rotate_assets(asset_type)
return
last_rotation = Arrow.utcfromtimestamp(last_rotation_timestamp)
difference = (Arrow.utcnow() - last_rotation) + timedelta(minutes=5)
- log.trace(f"Icons last rotated at {last_rotation} (difference: {difference}).")
+ log.trace(f"{asset_type.value.title()}s last rotated at {last_rotation} (difference: {difference}).")
if difference.days >= BrandingConfig.cycle_frequency:
- await self.rotate_icons()
+ await self.rotate_assets(asset_type)
- async def initiate_icon_rotation(self, available_icons: t.List[RemoteObject]) -> None:
+ async def initiate_rotation(self, asset_type: AssetType, available_assets: list[RemoteObject]) -> None:
"""
- Set up a new icon rotation.
+ Set up a new asset rotation.
- This function should be called whenever available icons change. This is generally the case when we enter
+ This function should be called whenever available asset groups change. This is generally the case when we enter
a new event, but potentially also when the assets of an on-going event change. In such cases, a reset
- of `cache_icons` is necessary, because it contains download URLs which may have gotten stale.
+ of the cache is necessary, because it contains download URLs which may have gotten stale.
- This function does not upload a new icon!
+ This function does not upload a new asset!
"""
- log.debug("Initiating new icon rotation.")
+ log.debug(f"Initiating new {asset_type.value} rotation.")
- await self.cache_icons.clear()
+ await self.asset_caches[asset_type].clear()
- new_state = {icon.download_url: 0 for icon in available_icons}
- await self.cache_icons.update(new_state)
+ new_state = {asset.download_url: 0 for asset in available_assets}
+ await self.asset_caches[asset_type].update(new_state)
- log.trace(f"Icon rotation initiated for {len(new_state)} icons.")
+ log.trace(f"{asset_type.value.title()} rotation initiated for {len(new_state)} assets.")
- await self.cache_information.set("icons_hash", compound_hash(available_icons))
+ await self.cache_information.set(f"{asset_type.value}s_hash", compound_hash(available_assets))
async def send_info_embed(self, channel_id: int, *, is_notification: bool) -> None:
"""
@@ -315,10 +307,12 @@ class Branding(commands.Cog):
"""
log.info(f"Entering event: '{event.path}'.")
- banner_success = await self.apply_banner(event.banner) # Only one asset ~ apply directly.
+ # Prepare and apply new icon and banner rotations
+ await self.initiate_rotation(AssetType.ICON, event.icons)
+ await self.initiate_rotation(AssetType.BANNER, event.banners)
- await self.initiate_icon_rotation(event.icons) # Prepare a new rotation.
- icon_success = await self.rotate_icons() # Apply an icon from the new rotation.
+ icon_success = await self.rotate_assets(AssetType.ICON)
+ banner_success = await self.rotate_assets(AssetType.BANNER)
# This will only be False in the case of a manual same-event re-synchronisation.
event_changed = event.path != await self.cache_information.get("event_path")
@@ -413,7 +407,7 @@ class Branding(commands.Cog):
if should_begin:
self.daemon_loop.start()
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""
Cancel the daemon in case of cog unload.
@@ -453,16 +447,19 @@ class Branding(commands.Cog):
log.trace("Daemon main: event has not changed, checking for change in assets.")
- if new_event.banner.sha != await self.cache_information.get("banner_hash"):
+ if compound_hash(new_event.banners) != await self.cache_information.get("banners_hash"):
log.debug("Daemon main: detected banner change.")
- await self.apply_banner(new_event.banner)
+ await self.initiate_rotation(AssetType.BANNER, new_event.banners)
+ await self.rotate_assets(AssetType.BANNER)
+ else:
+ await self.maybe_rotate_assets(AssetType.BANNER)
if compound_hash(new_event.icons) != await self.cache_information.get("icons_hash"):
log.debug("Daemon main: detected icon change.")
- await self.initiate_icon_rotation(new_event.icons)
- await self.rotate_icons()
+ await self.initiate_rotation(AssetType.ICON, new_event.icons)
+ await self.rotate_assets(AssetType.ICON)
else:
- await self.maybe_rotate_icons()
+ await self.maybe_rotate_assets(AssetType.ICON)
@tasks.loop(hours=24)
async def daemon_loop(self) -> None:
diff --git a/bot/exts/backend/branding/_repository.py b/bot/exts/backend/branding/_repository.py
index d88ea67f3..e14f0a1ef 100644
--- a/bot/exts/backend/branding/_repository.py
+++ b/bot/exts/backend/branding/_repository.py
@@ -64,8 +64,8 @@ class Event(t.NamedTuple):
path: str # Path from repo root where event lives. This is the event's identity.
meta: MetaFile
- banner: RemoteObject
- icons: t.List[RemoteObject]
+ banners: list[RemoteObject]
+ icons: list[RemoteObject]
def __str__(self) -> str:
return f"<Event at '{self.path}'>"
@@ -163,21 +163,24 @@ class BrandingRepository:
"""
contents = await self.fetch_directory(directory.path)
- missing_assets = {"meta.md", "banner.png", "server_icons"} - contents.keys()
+ missing_assets = {"meta.md", "server_icons", "banners"} - contents.keys()
if missing_assets:
raise BrandingMisconfiguration(f"Directory is missing following assets: {missing_assets}")
server_icons = await self.fetch_directory(contents["server_icons"].path, types=("file",))
+ banners = await self.fetch_directory(contents["banners"].path, types=("file",))
if len(server_icons) == 0:
raise BrandingMisconfiguration("Found no server icons!")
+ if len(banners) == 0:
+ raise BrandingMisconfiguration("Found no server banners!")
meta_bytes = await self.fetch_file(contents["meta.md"].download_url)
meta_file = self.parse_meta_file(meta_bytes)
- return Event(directory.path, meta_file, contents["banner.png"], list(server_icons.values()))
+ return Event(directory.path, meta_file, list(banners.values()), list(server_icons.values()))
async def get_events(self) -> t.List[Event]:
"""
diff --git a/bot/exts/backend/config_verifier.py b/bot/exts/backend/config_verifier.py
index dc85a65a2..97c8869a1 100644
--- a/bot/exts/backend/config_verifier.py
+++ b/bot/exts/backend/config_verifier.py
@@ -3,7 +3,6 @@ from discord.ext.commands import Cog
from bot import constants
from bot.bot import Bot
from bot.log import get_logger
-from bot.utils import scheduling
log = get_logger(__name__)
@@ -13,9 +12,8 @@ class ConfigVerifier(Cog):
def __init__(self, bot: Bot):
self.bot = bot
- self.channel_verify_task = scheduling.create_task(self.verify_channels(), event_loop=self.bot.loop)
- async def verify_channels(self) -> None:
+ async def cog_load(self) -> None:
"""
Verify channels.
@@ -34,6 +32,6 @@ class ConfigVerifier(Cog):
log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.")
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the ConfigVerifier cog."""
- bot.add_cog(ConfigVerifier(bot))
+ await bot.add_cog(ConfigVerifier(bot))
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index c79c7b2a7..761991488 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -1,10 +1,11 @@
+import copy
import difflib
+from botcore.site_api import ResponseCodeError
from discord import Embed
from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors
from sentry_sdk import push_scope
-from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Colours, Icons, MODERATION_ROLES
from bot.errors import InvalidInfractedUserError, LockedResourceError
@@ -65,6 +66,8 @@ class ErrorHandler(Cog):
if isinstance(e, errors.CommandNotFound) and not getattr(ctx, "invoked_from_error_handler", False):
if await self.try_silence(ctx):
return
+ if await self.try_run_eval(ctx):
+ return
await self.try_get_tag(ctx) # Try to look for a tag with the command's name
elif isinstance(e, errors.UserInputError):
log.debug(debug_message)
@@ -179,6 +182,30 @@ class ErrorHandler(Cog):
if not any(role.id in MODERATION_ROLES for role in ctx.author.roles):
await self.send_command_suggestion(ctx, ctx.invoked_with)
+ async def try_run_eval(self, ctx: Context) -> bool:
+ """
+ Attempt to run eval command with backticks directly after command.
+
+ For example: !eval```print("hi")```
+
+ Return True if command was invoked, else False
+ """
+ msg = copy.copy(ctx.message)
+
+ command, sep, end = msg.content.partition("```")
+ msg.content = command + " " + sep + end
+ new_ctx = await self.bot.get_context(msg)
+
+ eval_command = self.bot.get_command("eval")
+ if eval_command is None or new_ctx.command != eval_command:
+ return False
+
+ log.debug("Running fixed eval command.")
+ new_ctx.invoked_from_error_handler = True
+ await self.bot.invoke(new_ctx)
+
+ return True
+
async def send_command_suggestion(self, ctx: Context, command_name: str) -> None:
"""Sends user similar commands if any can be found."""
# No similar tag found, or tag on cooldown -
@@ -284,8 +311,11 @@ class ErrorHandler(Cog):
await ctx.send("There does not seem to be anything matching your query.")
ctx.bot.stats.incr("errors.api_error_404")
elif e.status == 400:
- content = await e.response.json()
- log.debug(f"API responded with 400 for command {ctx.command}: %r.", content)
+ log.error(
+ "API responded with 400 for command %s: %r.",
+ ctx.command,
+ e.response_json or e.response_text,
+ )
await ctx.send("According to the API, your request is malformed.")
ctx.bot.stats.incr("errors.api_error_400")
elif 500 <= e.status < 600:
@@ -328,6 +358,6 @@ class ErrorHandler(Cog):
log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the ErrorHandler cog."""
- bot.add_cog(ErrorHandler(bot))
+ await bot.add_cog(ErrorHandler(bot))
diff --git a/bot/exts/backend/logging.py b/bot/exts/backend/logging.py
index 2d03cd580..b9504c2eb 100644
--- a/bot/exts/backend/logging.py
+++ b/bot/exts/backend/logging.py
@@ -1,10 +1,10 @@
+from botcore.utils import scheduling
from discord import Embed
from discord.ext.commands import Cog
from bot.bot import Bot
from bot.constants import Channels, DEBUG_MODE
from bot.log import get_logger
-from bot.utils import scheduling
log = get_logger(__name__)
@@ -36,6 +36,6 @@ class Logging(Cog):
await self.bot.get_channel(Channels.dev_log).send(embed=embed)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Logging cog."""
- bot.add_cog(Logging(bot))
+ await bot.add_cog(Logging(bot))
diff --git a/bot/exts/backend/sync/__init__.py b/bot/exts/backend/sync/__init__.py
index 829098f79..1978917e6 100644
--- a/bot/exts/backend/sync/__init__.py
+++ b/bot/exts/backend/sync/__init__.py
@@ -1,8 +1,8 @@
from bot.bot import Bot
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Sync cog."""
# Defer import to reduce side effects from importing the sync package.
from bot.exts.backend.sync._cog import Sync
- bot.add_cog(Sync(bot))
+ await bot.add_cog(Sync(bot))
diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py
index 80f5750bc..433ff5024 100644
--- a/bot/exts/backend/sync/_cog.py
+++ b/bot/exts/backend/sync/_cog.py
@@ -1,17 +1,18 @@
+import asyncio
from typing import Any, Dict
+from botcore.site_api import ResponseCodeError
from discord import Member, Role, User
from discord.ext import commands
from discord.ext.commands import Cog, Context
from bot import constants
-from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.exts.backend.sync import _syncers
from bot.log import get_logger
-from bot.utils import scheduling
log = get_logger(__name__)
+MAX_ATTEMPTS = 3
class Sync(Cog):
@@ -19,9 +20,8 @@ class Sync(Cog):
def __init__(self, bot: Bot) -> None:
self.bot = bot
- scheduling.create_task(self.sync_guild(), event_loop=self.bot.loop)
- async def sync_guild(self) -> None:
+ async def cog_load(self) -> None:
"""Syncs the roles/users of the guild with the database."""
await self.bot.wait_until_guild_available()
@@ -29,6 +29,22 @@ class Sync(Cog):
if guild is None:
return
+ attempts = 0
+ while True:
+ attempts += 1
+ if guild.chunked:
+ log.info("Guild was found to be chunked after %d attempt(s).", attempts)
+ break
+
+ if attempts == MAX_ATTEMPTS:
+ log.info("Guild not chunked after %d attempts, calling chunk manually.", MAX_ATTEMPTS)
+ await guild.chunk()
+ break
+
+ log.info("Attempt %d/%d: Guild not yet chunked, checking again in 10s.", attempts, MAX_ATTEMPTS)
+ await asyncio.sleep(10)
+
+ log.info("Starting syncers.")
for syncer in (_syncers.RoleSyncer, _syncers.UserSyncer):
await syncer.sync(guild)
diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py
index 45301b098..799137cb9 100644
--- a/bot/exts/backend/sync/_syncers.py
+++ b/bot/exts/backend/sync/_syncers.py
@@ -2,14 +2,14 @@ import abc
import typing as t
from collections import namedtuple
+import discord.errors
+from botcore.site_api import ResponseCodeError
from discord import Guild
from discord.ext.commands import Context
from more_itertools import chunked
import bot
-from bot.api import ResponseCodeError
from bot.log import get_logger
-from bot.utils.members import get_or_fetch_member
log = get_logger(__name__)
@@ -157,7 +157,16 @@ class UserSyncer(Syncer):
if db_user[db_field] != guild_value:
updated_fields[db_field] = guild_value
- if guild_user := await get_or_fetch_member(guild, db_user["id"]):
+ guild_user = guild.get_member(db_user["id"])
+ if not guild_user and db_user["in_guild"]:
+ # The member was in the guild during the last sync.
+ # We try to fetch them to verify cache integrity.
+ try:
+ guild_user = await guild.fetch_member(db_user["id"])
+ except discord.errors.NotFound:
+ guild_user = None
+
+ if guild_user:
seen_guild_users.add(guild_user.id)
maybe_update("name", guild_user.name)
diff --git a/bot/exts/events/code_jams/__init__.py b/bot/exts/events/code_jams/__init__.py
index 16e81e365..2f858d1f9 100644
--- a/bot/exts/events/code_jams/__init__.py
+++ b/bot/exts/events/code_jams/__init__.py
@@ -1,8 +1,8 @@
from bot.bot import Bot
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the CodeJams cog."""
from bot.exts.events.code_jams._cog import CodeJams
- bot.add_cog(CodeJams(bot))
+ await bot.add_cog(CodeJams(bot))
diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py
index 452199f5f..86c357863 100644
--- a/bot/exts/events/code_jams/_cog.py
+++ b/bot/exts/events/code_jams/_cog.py
@@ -12,7 +12,7 @@ from bot.constants import Emojis, Roles
from bot.exts.events.code_jams import _channels
from bot.log import get_logger
from bot.utils.members import get_or_fetch_member
-from bot.utils.services import send_to_paste_service
+from bot.utils.services import PasteTooLongError, PasteUploadError, send_to_paste_service
log = get_logger(__name__)
@@ -139,11 +139,14 @@ class CodeJams(commands.Cog):
format_category_info(category, channels) for category, channels in categories.items()
)
- url = await send_to_paste_service(deletion_details)
- if url is None:
- url = "**Unable to send deletion details to the pasting service.**"
+ try:
+ message = await send_to_paste_service(deletion_details)
+ except PasteTooLongError:
+ message = "**Too long to upload to paste service.**"
+ except PasteUploadError:
+ message = "**Failed to upload to paste service.**"
- return f"Are you sure you want to delete all code jam channels?\n\nThe channels to be deleted: {url}"
+ return f"Are you sure you want to delete all code jam channels?\n\nThe channels to be deleted: {message}"
@codejam.command()
@commands.has_any_role(Roles.admins, Roles.code_jam_event_team)
diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py
index d727f7940..ff39700a6 100644
--- a/bot/exts/filters/antimalware.py
+++ b/bot/exts/filters/antimalware.py
@@ -18,14 +18,8 @@ PY_EMBED_DESCRIPTION = (
TXT_LIKE_FILES = {".txt", ".csv", ".json"}
TXT_EMBED_DESCRIPTION = (
- "**Uh-oh!** It looks like your message got zapped by our spam filter. "
- "We currently don't allow `{blocked_extension}` attachments, "
- "so here are some tips to help you travel safely: \n\n"
- "• If you attempted to send a message longer than 2000 characters, try shortening your message "
- "to fit within the character limit or use a pasting service (see below) \n\n"
- "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in "
- "{cmd_channel_mention} for more information) or use a pasting service like: "
- f"\n\n{URLs.site_schema}{URLs.site_paste}"
+ "You either uploaded a `{blocked_extension}` file or entered a message that was too long. "
+ f"Please use our [paste bin]({URLs.site_schema}{URLs.site_paste}) instead."
)
DISALLOWED_EMBED_DESCRIPTION = (
@@ -107,6 +101,6 @@ class AntiMalware(Cog):
log.info(f"Tried to delete message `{message.id}`, but message could not be found.")
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the AntiMalware cog."""
- bot.add_cog(AntiMalware(bot))
+ await bot.add_cog(AntiMalware(bot))
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index ddfd11231..9ebf0268d 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -8,6 +8,7 @@ from operator import attrgetter, itemgetter
from typing import Dict, Iterable, List, Set
import arrow
+from botcore.utils import scheduling
from discord import Colour, Member, Message, NotFound, Object, TextChannel
from discord.ext.commands import Cog
@@ -20,7 +21,7 @@ from bot.converters import Duration
from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME
from bot.exts.moderation.modlog import ModLog
from bot.log import get_logger
-from bot.utils import lock, scheduling
+from bot.utils import lock
from bot.utils.message_cache import MessageCache
from bot.utils.messages import format_user, send_attachments
@@ -103,6 +104,7 @@ class DeletionContext:
mod_alert_message += content
await modlog.send_log_message(
+ content=", ".join(str(m.id) for m in self.members), # quality-of-life improvement for mobile moderators
icon_url=Icons.filtering,
colour=Colour(Colours.soft_red),
title="Spam detected!",
@@ -133,18 +135,12 @@ class AntiSpam(Cog):
self.max_interval = max_interval_config['interval']
self.cache = MessageCache(AntiSpamConfig.cache_size, newest_first=True)
- scheduling.create_task(
- self.alert_on_validation_error(),
- name="AntiSpam.alert_on_validation_error",
- event_loop=self.bot.loop,
- )
-
@property
def mod_log(self) -> ModLog:
"""Allows for easy access of the ModLog cog."""
return self.bot.get_cog("ModLog")
- async def alert_on_validation_error(self) -> None:
+ async def cog_load(self) -> None:
"""Unloads the cog and alerts admins if configuration validation failed."""
await self.bot.wait_until_guild_available()
if self.validation_errors:
@@ -321,7 +317,7 @@ def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]:
return validation_errors
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Validate the AntiSpam configs and load the AntiSpam cog."""
validation_errors = validate_config()
- bot.add_cog(AntiSpam(bot, validation_errors))
+ await bot.add_cog(AntiSpam(bot, validation_errors))
diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py
index ee5bd89f3..fc9cfbeca 100644
--- a/bot/exts/filters/filter_lists.py
+++ b/bot/exts/filters/filter_lists.py
@@ -1,16 +1,16 @@
+import re
from typing import Optional
+from botcore.site_api import ResponseCodeError
from discord import Colour, Embed
from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group, has_any_role
from bot import constants
-from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels
from bot.converters import ValidDiscordServerInvite, ValidFilterListType
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import scheduling
log = get_logger(__name__)
@@ -29,9 +29,8 @@ class FilterLists(Cog):
def __init__(self, bot: Bot) -> None:
self.bot = bot
- scheduling.create_task(self._amend_docstrings(), event_loop=self.bot.loop)
- async def _amend_docstrings(self) -> None:
+ async def cog_load(self) -> None:
"""Add the valid FilterList types to the docstrings, so they'll appear in !help invocations."""
await self.bot.wait_until_guild_available()
@@ -72,6 +71,18 @@ class FilterLists(Cog):
elif list_type == "FILE_FORMAT" and not content.startswith("."):
content = f".{content}"
+ # If it's a filter token, validate the passed regex
+ elif list_type == "FILTER_TOKEN":
+ try:
+ re.compile(content)
+ except re.error as e:
+ await ctx.message.add_reaction("❌")
+ await ctx.send(
+ f"{ctx.author.mention} that's not a valid regex! "
+ f"Regex error message: {e.msg}."
+ )
+ return
+
# Try to add the item to the database
log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}")
payload = {
@@ -275,6 +286,6 @@ class FilterLists(Cog):
return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the FilterLists cog."""
- bot.add_cog(FilterLists(bot))
+ await bot.add_cog(FilterLists(bot))
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 8accc61f8..70f59c1ee 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -1,28 +1,29 @@
import asyncio
import re
import unicodedata
+import urllib.parse
from datetime import timedelta
from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union
import arrow
import dateutil.parser
-import discord.errors
import regex
+import tldextract
from async_rediscache import RedisCache
+from botcore.site_api import ResponseCodeError
+from botcore.utils import scheduling
+from botcore.utils.regex import DISCORD_INVITE
from dateutil.relativedelta import relativedelta
-from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel
+from discord import ChannelType, Colour, Embed, Forbidden, HTTPException, Member, Message, NotFound, TextChannel
from discord.ext.commands import Cog
from discord.utils import escape_markdown
-from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Colours, Filter, Guild, Icons, URLs
+from bot.constants import Bot as BotConfig, Channels, Colours, Filter, Guild, Icons, URLs
from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME
from bot.exts.moderation.modlog import ModLog
from bot.log import get_logger
-from bot.utils import scheduling
from bot.utils.messages import format_user
-from bot.utils.regex import INVITE_RE
log = get_logger(__name__)
@@ -62,14 +63,14 @@ AUTO_BAN_REASON = (
)
AUTO_BAN_DURATION = timedelta(days=4)
-FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]]
+FilterMatch = Union[re.Match, dict, bool, List[Embed]]
class Stats(NamedTuple):
"""Additional stats on a triggered filter to append to a mod log."""
message_content: str
- additional_embeds: Optional[List[discord.Embed]]
+ additional_embeds: Optional[List[Embed]]
class Filtering(Cog):
@@ -149,9 +150,7 @@ class Filtering(Cog):
},
}
- scheduling.create_task(self.reschedule_offensive_msg_deletion(), event_loop=self.bot.loop)
-
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Cancel scheduled tasks."""
self.scheduler.cancel_all()
@@ -206,6 +205,11 @@ class Filtering(Cog):
delta = relativedelta(after.edited_at, before.edited_at).microseconds
await self._filter_message(after, delta)
+ @Cog.listener()
+ async def on_voice_state_update(self, member: Member, *_) -> None:
+ """Checks for bad words in usernames when users join, switch or leave a voice channel."""
+ await self.check_bad_words_in_name(member)
+
def get_name_match(self, name: str) -> Optional[re.Match]:
"""Check bad words from passed string (name). Return the first match found."""
normalised_name = unicodedata.normalize("NFKC", name)
@@ -255,20 +259,22 @@ class Filtering(Cog):
)
await self.mod_log.send_log_message(
+ content=str(member.id), # quality-of-life improvement for mobile moderators
icon_url=Icons.token_removed,
colour=Colours.soft_red,
title="Username filtering alert",
text=log_string,
channel_id=Channels.mod_alerts,
- thumbnail=member.display_avatar.url
+ thumbnail=member.display_avatar.url,
+ ping_everyone=True
)
# Update time when alert sent
await self.name_alerts.set(member.id, arrow.utcnow().timestamp())
- async def filter_eval(self, result: str, msg: Message) -> bool:
+ async def filter_snekbox_output(self, result: str, msg: Message) -> bool:
"""
- Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly.
+ Filter the result of a snekbox command to see if it violates any of our rules, and then respond accordingly.
Also requires the original message, to check whether to filter and for mod logs.
Returns whether a filter was triggered or not.
@@ -337,7 +343,7 @@ class Filtering(Cog):
match = result
if match:
- is_private = msg.channel.type is discord.ChannelType.private
+ is_private = msg.channel.type is ChannelType.private
# If this is a filter (not a watchlist) and not in a DM, delete the message.
if _filter["type"] == "filter" and not is_private:
@@ -352,7 +358,7 @@ class Filtering(Cog):
# In addition, to avoid sending two notifications to the user, the
# logs, and mod_alert, we return if the message no longer exists.
await msg.delete()
- except discord.errors.NotFound:
+ except NotFound:
return
# Notify the user if the filter specifies
@@ -407,14 +413,14 @@ class Filtering(Cog):
self,
filter_name: str,
_filter: Dict[str, Any],
- msg: discord.Message,
+ msg: Message,
stats: Stats,
reason: Optional[str] = None,
*,
is_eval: bool = False,
) -> None:
"""Send a mod log for a triggered filter."""
- if msg.channel.type is discord.ChannelType.private:
+ if msg.channel.type is ChannelType.private:
channel_str = "via DM"
ping_everyone = False
else:
@@ -422,11 +428,14 @@ class Filtering(Cog):
# Allow specific filters to override ping_everyone
ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True)
- # If we are going to autoban, we don't want to ping
+ content = str(msg.author.id) # quality-of-life improvement for mobile moderators
+
+ # If we are going to autoban, we don't want to ping and don't need the user ID
if reason and "[autoban]" in reason:
ping_everyone = False
+ content = None
- eval_msg = "using !eval " if is_eval else ""
+ eval_msg = f"using {BotConfig.prefix}eval " if is_eval else ""
footer = f"Reason: {reason}" if reason else None
message = (
f"The {filter_name} {_filter['type']} was triggered by {format_user(msg.author)} "
@@ -438,6 +447,7 @@ class Filtering(Cog):
# Send pretty mod log embed to mod-alerts
await self.mod_log.send_log_message(
+ content=content,
icon_url=Icons.filtering,
colour=Colour(Colours.soft_red),
title=f"{_filter['type'].title()} triggered!",
@@ -472,7 +482,7 @@ class Filtering(Cog):
additional_embeds = []
for _, data in match.items():
reason = f"Reason: {data['reason']} | " if data.get('reason') else ""
- embed = discord.Embed(description=(
+ embed = Embed(description=(
f"**Members:**\n{data['members']}\n"
f"**Active:**\n{data['active']}"
))
@@ -535,7 +545,10 @@ class Filtering(Cog):
for match in URL_RE.finditer(text):
for url in domain_blacklist:
if url.lower() in match.group(1).lower():
- return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"]
+ blacklisted_parsed = tldextract.extract(url.lower())
+ url_parsed = tldextract.extract(match.group(1).lower())
+ if blacklisted_parsed.registered_domain == url_parsed.registered_domain:
+ return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"]
return False, None
@staticmethod
@@ -553,6 +566,7 @@ class Filtering(Cog):
If any are detected, a dictionary of invite data is returned, with a key per invite.
If none are detected, False is returned.
+ If we are unable to process an invite, True is returned.
Attempts to catch some of common ways to try to cheat the system.
"""
@@ -562,9 +576,10 @@ class Filtering(Cog):
# discord\.gg/gdudes-pony-farm
text = text.replace("\\", "")
- invites = [m.group("invite") for m in INVITE_RE.finditer(text)]
+ invites = [m.group("invite") for m in DISCORD_INVITE.finditer(text)]
invite_data = dict()
for invite in invites:
+ invite = urllib.parse.quote_plus(invite.rstrip("/"))
if invite in invite_data:
continue
@@ -617,7 +632,7 @@ class Filtering(Cog):
return invite_data if invite_data else False
@staticmethod
- async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]:
+ async def _has_rich_embed(msg: Message) -> Union[bool, List[Embed]]:
"""Determines if `msg` contains any rich embeds not auto-generated from a URL."""
if msg.embeds:
for embed in msg.embeds:
@@ -653,7 +668,7 @@ class Filtering(Cog):
"""
try:
await filtered_member.send(reason)
- except discord.errors.Forbidden:
+ except Forbidden:
await channel.send(f"{filtered_member.mention} {reason}")
def schedule_msg_delete(self, msg: dict) -> None:
@@ -661,7 +676,7 @@ class Filtering(Cog):
delete_at = dateutil.parser.isoparse(msg['delete_date'])
self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg))
- async def reschedule_offensive_msg_deletion(self) -> None:
+ async def cog_load(self) -> None:
"""Get all the pending message deletion from the API and reschedule them."""
await self.bot.wait_until_ready()
response = await self.bot.api_client.get('bot/offensive-messages',)
@@ -704,6 +719,6 @@ class Filtering(Cog):
return INVISIBLE_RE.sub("", no_zalgo)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Filtering cog."""
- bot.add_cog(Filtering(bot))
+ await bot.add_cog(Filtering(bot))
diff --git a/bot/exts/filters/security.py b/bot/exts/filters/security.py
index fe3918423..27e4d9752 100644
--- a/bot/exts/filters/security.py
+++ b/bot/exts/filters/security.py
@@ -25,6 +25,6 @@ class Security(Cog):
return True
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Security cog."""
- bot.add_cog(Security(bot))
+ await bot.add_cog(Security(bot))
diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py
index 520283ba3..a0d5aa7b6 100644
--- a/bot/exts/filters/token_remover.py
+++ b/bot/exts/filters/token_remover.py
@@ -1,5 +1,4 @@
import base64
-import binascii
import re
import typing as t
@@ -182,7 +181,7 @@ class TokenRemover(Cog):
# that means it's not a valid user id.
return None
return int(string)
- except (binascii.Error, ValueError):
+ except ValueError:
return None
@staticmethod
@@ -198,7 +197,7 @@ class TokenRemover(Cog):
try:
decoded_bytes = base64.urlsafe_b64decode(b64_content)
timestamp = int.from_bytes(decoded_bytes, byteorder="big")
- except (binascii.Error, ValueError) as e:
+ except ValueError as e:
log.debug(f"Failed to decode token timestamp '{b64_content}': {e}")
return False
@@ -229,6 +228,6 @@ class TokenRemover(Cog):
return True
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the TokenRemover cog."""
- bot.add_cog(TokenRemover(bot))
+ await bot.add_cog(TokenRemover(bot))
diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py
index 96334317c..b42613804 100644
--- a/bot/exts/filters/webhook_remover.py
+++ b/bot/exts/filters/webhook_remover.py
@@ -89,6 +89,6 @@ class WebhookRemover(Cog):
await self.on_message(after)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load `WebhookRemover` cog."""
- bot.add_cog(WebhookRemover(bot))
+ await bot.add_cog(WebhookRemover(bot))
diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py
index c51656343..1815e54f2 100644
--- a/bot/exts/fun/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
@@ -2,14 +2,13 @@ import asyncio
from typing import Union
import discord
-from discord import Color, Embed, Message, RawReactionActionEvent, TextChannel, errors
+from discord import Color, Embed, Message, RawReactionActionEvent, errors
from discord.ext.commands import Cog, Context, command
from bot import constants
from bot.bot import Bot
from bot.converters import MemberOrUser
from bot.log import get_logger
-from bot.utils import scheduling
from bot.utils.checks import has_any_role
from bot.utils.messages import count_unique_users_reaction, send_attachments
from bot.utils.webhooks import send_webhook
@@ -25,10 +24,9 @@ class DuckPond(Cog):
self.webhook_id = constants.Webhooks.duck_pond
self.webhook = None
self.ducked_messages = []
- scheduling.create_task(self.fetch_webhook(), event_loop=self.bot.loop)
self.relay_lock = None
- async def fetch_webhook(self) -> None:
+ async def cog_load(self) -> None:
"""Fetches the webhook object, so we can post to it."""
await self.bot.wait_until_guild_available()
@@ -46,17 +44,6 @@ class DuckPond(Cog):
return True
return False
- @staticmethod
- def is_helper_viewable(channel: TextChannel) -> bool:
- """Check if helpers can view a specific channel."""
- guild = channel.guild
- helper_role = guild.get_role(constants.Roles.helpers)
- # check channel overwrites for both the Helper role and @everyone and
- # return True for channels that they have permissions to view.
- helper_overwrites = channel.overwrites_for(helper_role)
- default_overwrites = channel.overwrites_for(guild.default_role)
- return default_overwrites.view_channel is None or helper_overwrites.view_channel is True
-
async def has_green_checkmark(self, message: Message) -> bool:
"""Check if the message has a green checkmark reaction."""
for reaction in message.reactions:
@@ -165,12 +152,15 @@ class DuckPond(Cog):
if not self._payload_has_duckpond_emoji(payload.emoji):
return
- channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id)
+ await self.bot.wait_until_guild_available()
+ guild = self.bot.get_guild(payload.guild_id)
+ channel = guild.get_channel_or_thread(payload.channel_id)
if channel is None:
return
# Was the message sent in a channel Helpers can see?
- if not self.is_helper_viewable(channel):
+ helper_role = guild.get_role(constants.Roles.helpers)
+ if not channel.permissions_for(helper_role).view_channel:
return
try:
@@ -226,6 +216,6 @@ class DuckPond(Cog):
await ctx.message.add_reaction("❌")
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the DuckPond cog."""
- bot.add_cog(DuckPond(bot))
+ await bot.add_cog(DuckPond(bot))
diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py
index 7df1d172d..b8206dab4 100644
--- a/bot/exts/fun/off_topic_names.py
+++ b/bot/exts/fun/off_topic_names.py
@@ -1,40 +1,54 @@
+import datetime
import difflib
-from datetime import timedelta
-import arrow
+from botcore.site_api import ResponseCodeError
from discord import Colour, Embed
+from discord.ext import tasks
from discord.ext.commands import Cog, Context, group, has_any_role
-from discord.utils import sleep_until
-from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, MODERATION_ROLES
+from bot.constants import Bot as BotConfig, Channels, MODERATION_ROLES
from bot.converters import OffTopicName
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import scheduling
CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2)
log = get_logger(__name__)
-async def update_names(bot: Bot) -> None:
- """Background updater task that performs the daily channel name update."""
- while True:
- # Since we truncate the compute timedelta to seconds, we add one second to ensure
- # we go past midnight in the `seconds_to_sleep` set below.
- today_at_midnight = arrow.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)
- next_midnight = today_at_midnight + timedelta(days=1)
- await sleep_until(next_midnight.datetime)
+class OffTopicNames(Cog):
+ """Commands related to managing the off-topic category channel names."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ # What errors to handle and restart the task using an exponential back-off algorithm
+ self.update_names.add_exception_type(ResponseCodeError)
+ self.update_names.start()
+
+ async def cog_unload(self) -> None:
+ """
+ Gracefully stop the update_names task.
+
+ Clear the exception types first, so that if the task hits any errors it is not re-attempted.
+ """
+ self.update_names.clear_exception_types()
+ self.update_names.stop()
+
+ @tasks.loop(time=datetime.time(), reconnect=True)
+ async def update_names(self) -> None:
+ """Background updater task that performs the daily channel name update."""
+ await self.bot.wait_until_guild_available()
try:
- channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get(
+ channel_0_name, channel_1_name, channel_2_name = await self.bot.api_client.get(
'bot/off-topic-channel-names', params={'random_items': 3}
)
except ResponseCodeError as e:
log.error(f"Failed to get new off topic channel names: code {e.response.status}")
- continue
- channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS)
+ raise
+
+ channel_0, channel_1, channel_2 = (self.bot.get_channel(channel_id) for channel_id in CHANNELS)
await channel_0.edit(name=f'ot0-{channel_0_name}')
await channel_1.edit(name=f'ot1-{channel_1_name}')
@@ -44,28 +58,6 @@ async def update_names(bot: Bot) -> None:
f" {channel_0_name}, {channel_1_name} and {channel_2_name}"
)
-
-class OffTopicNames(Cog):
- """Commands related to managing the off-topic category channel names."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
- self.updater_task = None
-
- scheduling.create_task(self.init_offtopic_updater(), event_loop=self.bot.loop)
-
- def cog_unload(self) -> None:
- """Cancel any running updater tasks on cog unload."""
- if self.updater_task is not None:
- self.updater_task.cancel()
-
- async def init_offtopic_updater(self) -> None:
- """Start off-topic channel updating event loop if it hasn't already started."""
- await self.bot.wait_until_guild_available()
- if self.updater_task is None:
- coro = update_names(self.bot)
- self.updater_task = scheduling.create_task(coro, event_loop=self.bot.loop)
-
@group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True)
@has_any_role(*MODERATION_ROLES)
async def otname_group(self, ctx: Context) -> None:
@@ -90,7 +82,7 @@ class OffTopicNames(Cog):
)
await ctx.send(
f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. "
- "Use `!otn forceadd` to override this check."
+ f"Use `{BotConfig.prefix}otn forceadd` to override this check."
)
else:
await self._add_name(ctx, name)
@@ -167,6 +159,6 @@ class OffTopicNames(Cog):
await ctx.send(embed=embed)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the OffTopicNames cog."""
- bot.add_cog(OffTopicNames(bot))
+ await bot.add_cog(OffTopicNames(bot))
diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py
index beba18aa6..b9c940183 100644
--- a/bot/exts/help_channels/__init__.py
+++ b/bot/exts/help_channels/__init__.py
@@ -28,7 +28,7 @@ def validate_config() -> None:
)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the HelpChannels cog."""
# Defer import to reduce side effects from importing the help_channels package.
from bot.exts.help_channels._cog import HelpChannels
@@ -37,4 +37,4 @@ def setup(bot: Bot) -> None:
except ValueError as e:
log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}")
else:
- bot.add_cog(HelpChannels(bot))
+ await bot.add_cog(HelpChannels(bot))
diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py
index 8d45c2466..937c4ab57 100644
--- a/bot/exts/help_channels/_caches.py
+++ b/bot/exts/help_channels/_caches.py
@@ -17,10 +17,6 @@ claimant_last_message_times = RedisCache(namespace="HelpChannels.claimant_last_m
# RedisCache[discord.TextChannel.id, UtcPosixTimestamp]
non_claimant_last_message_times = RedisCache(namespace="HelpChannels.non_claimant_last_message_times")
-# This cache maps a help channel to original question message in same channel.
-# RedisCache[discord.TextChannel.id, discord.Message.id]
-question_messages = RedisCache(namespace="HelpChannels.question_messages")
-
# This cache keeps track of the dynamic message ID for
# the continuously updated message in the #How-to-get-help channel.
dynamic_message = RedisCache(namespace="HelpChannels.dynamic_message")
diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py
index e43c1e789..d9cebf215 100644
--- a/bot/exts/help_channels/_channel.py
+++ b/bot/exts/help_channels/_channel.py
@@ -1,3 +1,4 @@
+import re
import typing as t
from datetime import timedelta
from enum import Enum
@@ -16,13 +17,14 @@ log = get_logger(__name__)
MAX_CHANNELS_PER_CATEGORY = 50
EXCLUDED_CHANNELS = (constants.Channels.cooldown,)
+CLAIMED_BY_RE = re.compile(r"Channel claimed by <@!?(?P<user_id>\d{17,20})>\.$")
class ClosingReason(Enum):
"""All possible closing reasons for help channels."""
COMMAND = "command"
- LATEST_MESSSAGE = "auto.latest_message"
+ LATEST_MESSAGE = "auto.latest_message"
CLAIMANT_TIMEOUT = "auto.claimant_timeout"
OTHER_TIMEOUT = "auto.other_timeout"
DELETED = "auto.deleted"
@@ -75,7 +77,7 @@ async def get_closing_time(channel: discord.TextChannel, init_done: bool) -> t.T
# Use the greatest offset to avoid the possibility of prematurely closing the channel.
time = Arrow.fromdatetime(msg.created_at) + timedelta(minutes=idle_minutes_claimant)
- reason = ClosingReason.DELETED if is_empty else ClosingReason.LATEST_MESSSAGE
+ reason = ClosingReason.DELETED if is_empty else ClosingReason.LATEST_MESSAGE
return time, reason
claimant_time = Arrow.utcfromtimestamp(claimant_time)
@@ -157,3 +159,36 @@ async def move_to_bottom(channel: discord.TextChannel, category_id: int, **optio
# Now that the channel is moved, we can edit the other attributes
if options:
await channel.edit(**options)
+
+
+async def ensure_cached_claimant(channel: discord.TextChannel) -> None:
+ """
+ Ensure there is a claimant cached for each help channel.
+
+ Check the redis cache first, return early if there is already a claimant cached.
+ If there isn't an entry in redis, search for the "Claimed by X." embed in channel history.
+ Stopping early if we discover a dormant message first.
+
+ If a claimant could not be found, send a warning to #helpers and set the claimant to the bot.
+ """
+ if await _caches.claimants.get(channel.id):
+ return
+
+ async for message in channel.history(limit=1000):
+ if message.author.id != bot.instance.user.id:
+ # We only care about bot messages
+ continue
+ if message.embeds:
+ if _message._match_bot_embed(message, _message.DORMANT_MSG):
+ log.info("Hit the dormant message embed before finding a claimant in %s (%d).", channel, channel.id)
+ break
+ # Only set the claimant if the first embed matches the claimed channel embed regex
+ if match := CLAIMED_BY_RE.match(message.embeds[0].description):
+ await _caches.claimants.set(channel.id, int(match.group("user_id")))
+ return
+
+ await bot.instance.get_channel(constants.Channels.helpers).send(
+ f"I couldn't find a claimant for {channel.mention} in that last 1000 messages. "
+ "Please use your helper powers to close the channel if/when appropriate."
+ )
+ await _caches.claimants.set(channel.id, bot.instance.user.id)
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 60209ba6e..f1351013a 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -7,6 +7,7 @@ from operator import attrgetter
import arrow
import discord
import discord.abc
+from botcore.utils import members, scheduling
from discord.ext import commands
from bot import constants
@@ -14,7 +15,7 @@ from bot.bot import Bot
from bot.constants import Channels, RedirectOutput
from bot.exts.help_channels import _caches, _channel, _message, _name, _stats
from bot.log import get_logger
-from bot.utils import channel as channel_utils, lock, members, scheduling
+from bot.utils import channel as channel_utils, lock
log = get_logger(__name__)
@@ -78,19 +79,21 @@ class HelpChannels(commands.Cog):
self.channel_queue: asyncio.Queue[discord.TextChannel] = None
self.name_queue: t.Deque[str] = None
- self.last_notification: t.Optional[arrow.Arrow] = None
+ # Notifications
+ # Using a very old date so that we don't have to use Optional typing.
+ self.last_none_remaining_notification = arrow.get('1815-12-10T18:00:00.00000+00:00')
+ self.last_running_low_notification = arrow.get('1815-12-10T18:00:00.00000+00:00')
self.dynamic_message: t.Optional[int] = None
self.available_help_channels: t.Set[discord.TextChannel] = set()
# Asyncio stuff
self.queue_tasks: t.List[asyncio.Task] = []
- self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop)
+ self.init_done = False
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Cancel the init task and scheduled tasks when the cog unloads."""
log.trace("Cog unload: cancelling the init_cog task")
- self.init_task.cancel()
log.trace("Cog unload: cancelling the channel queue tasks")
for task in self.queue_tasks:
@@ -105,11 +108,36 @@ class HelpChannels(commands.Cog):
"""
Claim the channel in which the question `message` was sent.
- Move the channel to the In Use category and pin the `message`. Add a cooldown to the
- claimant to prevent them from asking another question. Lastly, make a new channel available.
+ Send an embed stating the claimant, move the channel to the In Use category, and pin the `message`.
+ Add a cooldown to the claimant to prevent them from asking another question.
+ Lastly, make a new channel available.
"""
log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.")
- await self.move_to_in_use(message.channel)
+
+ try:
+ await self.move_to_in_use(message.channel)
+ except discord.DiscordServerError:
+ try:
+ await message.channel.send(
+ "The bot encountered a Discord API error while trying to move this channel, please try again later."
+ )
+ except Exception as e:
+ log.warning("Error occurred while sending fail claim message:", exc_info=e)
+ log.info(
+ "500 error from Discord when moving #%s (%d) to in-use for %s (%d). Cancelling claim.",
+ message.channel.name,
+ message.channel.id,
+ message.author.name,
+ message.author.id,
+ )
+ self.bot.stats.incr("help.failed_claims.500_on_move")
+ return
+
+ embed = discord.Embed(
+ description=f"Channel claimed by {message.author.mention}.",
+ color=constants.Colours.bright_green,
+ )
+ await message.channel.send(embed=embed)
# Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839)
if not isinstance(message.author, discord.Member):
@@ -227,13 +255,21 @@ class HelpChannels(commands.Cog):
if not channel:
log.info("Couldn't create a candidate channel; waiting to get one from the queue.")
- notify_channel = self.bot.get_channel(constants.HelpChannels.notify_channel)
- last_notification = await _message.notify(notify_channel, self.last_notification)
+ last_notification = await _message.notify_none_remaining(self.last_none_remaining_notification)
+
if last_notification:
- self.last_notification = last_notification
- self.bot.stats.incr("help.out_of_channel_alerts")
+ self.last_none_remaining_notification = last_notification
+
+ channel = await self.wait_for_dormant_channel() # Blocks until a new channel is available
+
+ else:
+ last_notification = await _message.notify_running_low(
+ self.channel_queue.qsize(),
+ self.last_running_low_notification
+ )
- channel = await self.wait_for_dormant_channel()
+ if last_notification:
+ self.last_running_low_notification = last_notification
return channel
@@ -281,7 +317,7 @@ class HelpChannels(commands.Cog):
log.exception("Failed to get a category; cog will be removed")
self.bot.remove_cog(self.qualified_name)
- async def init_cog(self) -> None:
+ async def cog_load(self) -> None:
"""Initialise the help channel system."""
log.trace("Waiting for the guild to be available before initialisation.")
await self.bot.wait_until_guild_available()
@@ -301,6 +337,7 @@ class HelpChannels(commands.Cog):
log.trace("Moving or rescheduling in-use channels.")
for channel in _channel.get_category_channels(self.in_use_category):
+ await _channel.ensure_cached_claimant(channel)
await self.move_idle_channel(channel, has_task=False)
# Prevent the command from being used until ready.
@@ -316,6 +353,7 @@ class HelpChannels(commands.Cog):
await self.init_available()
_stats.report_counts()
+ self.init_done = True
log.info("Cog is ready!")
async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None:
@@ -327,7 +365,7 @@ class HelpChannels(commands.Cog):
"""
log.trace(f"Handling in-use channel #{channel} ({channel.id}).")
- closing_time, closed_on = await _channel.get_closing_time(channel, self.init_task.done())
+ closing_time, closed_on = await _channel.get_closing_time(channel, self.init_done)
# Closing time is in the past.
# Add 1 second due to POSIX timestamps being lower resolution than datetime objects.
@@ -356,17 +394,17 @@ class HelpChannels(commands.Cog):
log.trace("Making a channel available.")
channel = await self.get_available_candidate()
- log.info(f"Making #{channel} ({channel.id}) available.")
+ channel_str = f"#{channel} ({channel.id})"
+ log.info(f"Making {channel_str} available.")
await _message.send_available_message(channel)
- log.trace(f"Moving #{channel} ({channel.id}) to the Available category.")
+ log.trace(f"Moving {channel_str} to the Available category.")
# Unpin any previously stuck pins
- log.trace(f"Looking for pins stuck in #{channel} ({channel.id}).")
- for message in await channel.pins():
- await _message.pin_wrapper(message.id, channel, pin=False)
- log.debug(f"Removed a stuck pin from #{channel} ({channel.id}). ID: {message.id}")
+ log.trace(f"Looking for pins stuck in {channel_str}.")
+ if stuck_pins := await _message.unpin_all(channel):
+ log.debug(f"Removed {stuck_pins} stuck pins from {channel_str}.")
await _channel.move_to_bottom(
channel=channel,
@@ -426,20 +464,23 @@ class HelpChannels(commands.Cog):
async def _unclaim_channel(
self,
channel: discord.TextChannel,
- claimant_id: int,
+ claimant_id: t.Optional[int],
closed_on: _channel.ClosingReason
) -> None:
"""Actual implementation of `unclaim_channel`. See that for full documentation."""
await _caches.claimants.delete(channel.id)
await _caches.session_participants.delete(channel.id)
- claimant = await members.get_or_fetch_member(self.guild, claimant_id)
- if claimant is None:
- log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed")
+ if not claimant_id:
+ log.info("No claimant given when un-claiming %s (%d). Skipping role removal.", channel, channel.id)
else:
- await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role)
+ claimant = await members.get_or_fetch_member(self.guild, claimant_id)
+ if claimant is None:
+ log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed")
+ else:
+ await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role)
- await _message.unpin(channel)
+ await _message.unpin_all(channel)
await _stats.report_complete_session(channel.id, closed_on)
await self.move_to_dormant(channel)
@@ -469,8 +510,6 @@ class HelpChannels(commands.Cog):
if message.author.bot:
return # Ignore messages sent by bots.
- await self.init_task
-
if channel_utils.is_in_category(message.channel, constants.Categories.help_available):
if not _channel.is_excluded_channel(message.channel):
await self.claim_channel(message)
@@ -486,8 +525,6 @@ class HelpChannels(commands.Cog):
The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`.
"""
- await self.init_task
-
if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use):
return
@@ -526,19 +563,18 @@ class HelpChannels(commands.Cog):
if self.dynamic_message is not None:
try:
log.trace("Help channels have changed, dynamic message has been edited.")
- await self.bot.http.edit_message(
- constants.Channels.how_to_get_help, self.dynamic_message, content=available_channels
- )
+ await discord.PartialMessage(
+ channel=self.bot.get_channel(constants.Channels.how_to_get_help),
+ id=self.dynamic_message,
+ ).edit(content=available_channels)
except discord.NotFound:
pass
else:
return
log.trace("Dynamic message could not be edited or found. Creating a new one.")
- new_dynamic_message = await self.bot.http.send_message(
- constants.Channels.how_to_get_help, available_channels
- )
- self.dynamic_message = new_dynamic_message["id"]
+ new_dynamic_message = await self.bot.get_channel(constants.Channels.how_to_get_help).send(available_channels)
+ self.dynamic_message = new_dynamic_message.id
await _caches.dynamic_message.set("message_id", self.dynamic_message)
@staticmethod
diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py
index 241dd606c..00d57ea40 100644
--- a/bot/exts/help_channels/_message.py
+++ b/bot/exts/help_channels/_message.py
@@ -27,7 +27,7 @@ For more tips, check out our guide on [asking good questions]({ASKING_GUIDE_URL}
AVAILABLE_TITLE = "Available help channel"
-AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close."
+AVAILABLE_FOOTER = f"Closes after a period of inactivity, or when you send {constants.Bot.prefix}close."
DORMANT_MSG = f"""
This help channel has been marked as **dormant**, and has been moved into the **{{dormant}}** \
@@ -66,11 +66,11 @@ async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.M
"""Return the last message sent in the channel or None if no messages exist."""
log.trace(f"Getting the last message in #{channel} ({channel.id}).")
- try:
- return await channel.history(limit=1).next() # noqa: B305
- except discord.NoMoreItems:
- log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.")
- return None
+ async for message in channel.history(limit=1):
+ return message
+
+ log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.")
+ return None
async def is_empty(channel: discord.TextChannel) -> bool:
@@ -124,58 +124,98 @@ async def dm_on_open(message: discord.Message) -> None:
)
-async def notify(channel: discord.TextChannel, last_notification: t.Optional[Arrow]) -> t.Optional[Arrow]:
+async def notify_none_remaining(last_notification: Arrow) -> t.Optional[Arrow]:
"""
- Send a message in `channel` notifying about a lack of available help channels.
+ Send a pinging message in `channel` notifying about there being no dormant channels remaining.
If a notification was sent, return the time at which the message was sent.
Otherwise, return None.
Configuration:
-
- * `HelpChannels.notify` - toggle notifications
- * `HelpChannels.notify_minutes` - minimum interval between notifications
- * `HelpChannels.notify_roles` - roles mentioned in notifications
+ * `HelpChannels.notify_minutes` - minimum interval between notifications
+ * `HelpChannels.notify_none_remaining` - toggle none_remaining notifications
+ * `HelpChannels.notify_none_remaining_roles` - roles mentioned in notifications
"""
- if not constants.HelpChannels.notify:
- return
+ if not constants.HelpChannels.notify_none_remaining:
+ return None
+
+ if (arrow.utcnow() - last_notification).total_seconds() < (constants.HelpChannels.notify_minutes * 60):
+ log.trace("Did not send none_remaining notification as it hasn't been enough time since the last one.")
+ return None
log.trace("Notifying about lack of channels.")
- if last_notification:
- elapsed = (arrow.utcnow() - last_notification).seconds
- minimum_interval = constants.HelpChannels.notify_minutes * 60
- should_send = elapsed >= minimum_interval
- else:
- should_send = True
+ mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_none_remaining_roles)
+ allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_none_remaining_roles]
- if not should_send:
- log.trace("Notification not sent because it's too recent since the previous one.")
- return
+ channel = bot.instance.get_channel(constants.HelpChannels.notify_channel)
+ if channel is None:
+ log.trace("Did not send none_remaining notification as the notification channel couldn't be gathered.")
try:
- log.trace("Sending notification message.")
-
- mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles)
- allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles]
-
- message = await channel.send(
+ await channel.send(
f"{mentions} A new available help channel is needed but there "
- f"are no more dormant ones. Consider freeing up some in-use channels manually by "
+ "are no more dormant ones. Consider freeing up some in-use channels manually by "
f"using the `{constants.Bot.prefix}dormant` command within the channels.",
allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles)
)
-
- return Arrow.fromdatetime(message.created_at)
except Exception:
# Handle it here cause this feature isn't critical for the functionality of the system.
log.exception("Failed to send notification about lack of dormant channels!")
+ else:
+ bot.instance.stats.incr("help.out_of_channel_alerts")
+ return arrow.utcnow()
+
+
+async def notify_running_low(number_of_channels_left: int, last_notification: Arrow) -> t.Optional[Arrow]:
+ """
+ Send a non-pinging message in `channel` notifying about there being a low amount of dormant channels.
+
+ This will include the number of dormant channels left `number_of_channels_left`
+
+ If a notification was sent, return the time at which the message was sent.
+ Otherwise, return None.
+
+ Configuration:
+ * `HelpChannels.notify_minutes` - minimum interval between notifications
+ * `HelpChannels.notify_running_low` - toggle running_low notifications
+ * `HelpChannels.notify_running_low_threshold` - minimum amount of channels to trigger running_low notifications
+ """
+ if not constants.HelpChannels.notify_running_low:
+ return None
+
+ if number_of_channels_left > constants.HelpChannels.notify_running_low_threshold:
+ log.trace("Did not send notify_running_low notification as the threshold was not met.")
+ return None
+
+ if (arrow.utcnow() - last_notification).total_seconds() < (constants.HelpChannels.notify_minutes * 60):
+ log.trace("Did not send notify_running_low notification as it hasn't been enough time since the last one.")
+ return None
+
+ log.trace("Notifying about getting close to no dormant channels.")
+
+ channel = bot.instance.get_channel(constants.HelpChannels.notify_channel)
+ if channel is None:
+ log.trace("Did not send notify_running notification as the notification channel couldn't be gathered.")
+
+ try:
+ if number_of_channels_left == 1:
+ message = f"There is only {number_of_channels_left} dormant channel left. "
+ else:
+ message = f"There are only {number_of_channels_left} dormant channels left. "
+ message += "Consider participating in some help channels so that we don't run out."
+ await channel.send(message)
+ except Exception:
+ # Handle it here cause this feature isn't critical for the functionality of the system.
+ log.exception("Failed to send notification about running low of dormant channels!")
+ else:
+ bot.instance.stats.incr("help.running_low_alerts")
+ return arrow.utcnow()
async def pin(message: discord.Message) -> None:
- """Pin an initial question `message` and store it in a cache."""
- if await pin_wrapper(message.id, message.channel, pin=True):
- await _caches.question_messages.set(message.channel.id, message.id)
+ """Pin an initial question `message`."""
+ await _pin_wrapper(message, pin=True)
async def send_available_message(channel: discord.TextChannel) -> None:
@@ -199,13 +239,14 @@ async def send_available_message(channel: discord.TextChannel) -> None:
await channel.send(embed=embed)
-async def unpin(channel: discord.TextChannel) -> None:
- """Unpin the initial question message sent in `channel`."""
- msg_id = await _caches.question_messages.pop(channel.id)
- if msg_id is None:
- log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.")
- else:
- await pin_wrapper(msg_id, channel, pin=False)
+async def unpin_all(channel: discord.TextChannel) -> int:
+ """Unpin all pinned messages in `channel` and return the amount of unpinned messages."""
+ count = 0
+ for message in await channel.pins():
+ if await _pin_wrapper(message, pin=False):
+ count += 1
+
+ return count
def _match_bot_embed(message: t.Optional[discord.Message], description: str) -> bool:
@@ -214,36 +255,32 @@ def _match_bot_embed(message: t.Optional[discord.Message], description: str) ->
return False
bot_msg_desc = message.embeds[0].description
- if bot_msg_desc is discord.Embed.Empty:
+ if bot_msg_desc is None:
log.trace("Last message was a bot embed but it was empty.")
return False
return message.author == bot.instance.user and bot_msg_desc.strip() == description.strip()
-async def pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool:
+async def _pin_wrapper(message: discord.Message, *, pin: bool) -> bool:
"""
- Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False.
+ Pin `message` if `pin` is True or unpin if it's False.
Return True if successful and False otherwise.
"""
- channel_str = f"#{channel} ({channel.id})"
- if pin:
- func = bot.instance.http.pin_message
- verb = "pin"
- else:
- func = bot.instance.http.unpin_message
- verb = "unpin"
+ channel_str = f"#{message.channel} ({message.channel.id})"
+ func = message.pin if pin else message.unpin
try:
- await func(channel.id, msg_id)
+ await func()
except discord.HTTPException as e:
if e.code == 10008:
- log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.")
+ log.debug(f"Message {message.id} in {channel_str} doesn't exist; can't {func.__name__}.")
else:
log.exception(
- f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})"
+ f"Error {func.__name__}ning message {message.id} in {channel_str}: "
+ f"{e.status} ({e.code})"
)
return False
else:
- log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.")
+ log.trace(f"{func.__name__.capitalize()}ned message {message.id} in {channel_str}.")
return True
diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py
index 07b1b8a2d..bfe32459e 100644
--- a/bot/exts/info/code_snippets.py
+++ b/bot/exts/info/code_snippets.py
@@ -246,6 +246,9 @@ class CodeSnippets(Cog):
if message.author.bot:
return
+ if message.guild is None:
+ return
+
message_to_send = await self._parse_snippets(message.content)
destination = message.channel
@@ -272,6 +275,6 @@ class CodeSnippets(Cog):
)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the CodeSnippets cog."""
- bot.add_cog(CodeSnippets(bot))
+ await bot.add_cog(CodeSnippets(bot))
diff --git a/bot/exts/info/codeblock/__init__.py b/bot/exts/info/codeblock/__init__.py
index 5c55bc5e3..dde45bd59 100644
--- a/bot/exts/info/codeblock/__init__.py
+++ b/bot/exts/info/codeblock/__init__.py
@@ -1,8 +1,8 @@
from bot.bot import Bot
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the CodeBlockCog cog."""
# Defer import to reduce side effects from importing the codeblock package.
from bot.exts.info.codeblock._cog import CodeBlockCog
- bot.add_cog(CodeBlockCog(bot))
+ await bot.add_cog(CodeBlockCog(bot))
diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py
index a859d8cef..9027105d9 100644
--- a/bot/exts/info/codeblock/_cog.py
+++ b/bot/exts/info/codeblock/_cog.py
@@ -2,6 +2,7 @@ import time
from typing import Optional
import discord
+from botcore.utils import scheduling
from discord import Message, RawMessageUpdateEvent
from discord.ext.commands import Cog
@@ -11,7 +12,7 @@ from bot.exts.filters.token_remover import TokenRemover
from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.exts.info.codeblock._instructions import get_instructions
from bot.log import get_logger
-from bot.utils import has_lines, scheduling
+from bot.utils import has_lines
from bot.utils.channel import is_help_channel
from bot.utils.messages import wait_for_deletion
diff --git a/bot/exts/info/doc/__init__.py b/bot/exts/info/doc/__init__.py
index facdf4d0b..4cfec33d3 100644
--- a/bot/exts/info/doc/__init__.py
+++ b/bot/exts/info/doc/__init__.py
@@ -11,7 +11,7 @@ NAMESPACE = "doc"
doc_cache = DocRedisCache(namespace=NAMESPACE)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Doc cog."""
from ._cog import DocCog
- bot.add_cog(DocCog(bot))
+ await bot.add_cog(DocCog(bot))
diff --git a/bot/exts/info/doc/_batch_parser.py b/bot/exts/info/doc/_batch_parser.py
index c27f28eac..41a15fb6e 100644
--- a/bot/exts/info/doc/_batch_parser.py
+++ b/bot/exts/info/doc/_batch_parser.py
@@ -8,12 +8,12 @@ from operator import attrgetter
from typing import Deque, Dict, List, NamedTuple, Optional, Union
import discord
+from botcore.utils import scheduling
from bs4 import BeautifulSoup
import bot
from bot.constants import Channels
from bot.log import get_logger
-from bot.utils import scheduling
from . import _cog, doc_cache
from ._parsing import get_symbol_markdown
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index ebf5f5932..c35349c3c 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -6,22 +6,21 @@ import textwrap
from collections import defaultdict
from contextlib import suppress
from types import SimpleNamespace
-from typing import Dict, NamedTuple, Optional, Tuple, Union
+from typing import Dict, Literal, NamedTuple, Optional, Tuple, Union
import aiohttp
import discord
+from botcore.site_api import ResponseCodeError
+from botcore.utils.scheduling import Scheduler
from discord.ext import commands
-from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import MODERATION_ROLES, RedirectOutput
-from bot.converters import Inventory, PackageName, ValidURL, allowed_strings
+from bot.converters import Inventory, PackageName, ValidURL
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import scheduling
from bot.utils.lock import SharedEvent, lock
from bot.utils.messages import send_denial, wait_for_deletion
-from bot.utils.scheduling import Scheduler
from . import NAMESPACE, PRIORITY_PACKAGES, _batch_parser, doc_cache
from ._inventory_parser import InvalidHeaderError, InventoryDict, fetch_inventory
@@ -78,14 +77,7 @@ class DocCog(commands.Cog):
self.refresh_event.set()
self.symbol_get_event = SharedEvent()
- self.init_refresh_task = scheduling.create_task(
- self.init_refresh_inventory(),
- name="Doc inventory init",
- event_loop=self.bot.loop,
- )
-
- @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True)
- async def init_refresh_inventory(self) -> None:
+ async def cog_load(self) -> None:
"""Refresh documentation inventory on cog initialization."""
await self.bot.wait_until_guild_available()
await self.refresh_inventories()
@@ -439,7 +431,7 @@ class DocCog(commands.Cog):
async def refresh_command(self, ctx: commands.Context) -> None:
"""Refresh inventories and show the difference."""
old_inventories = set(self.base_urls)
- with ctx.typing():
+ async with ctx.typing():
await self.refresh_inventories()
new_inventories = set(self.base_urls)
@@ -460,17 +452,16 @@ class DocCog(commands.Cog):
async def clear_cache_command(
self,
ctx: commands.Context,
- package_name: Union[PackageName, allowed_strings("*")] # noqa: F722
+ package_name: Union[PackageName, Literal["*"]]
) -> None:
"""Clear the persistent redis cache for `package`."""
if await doc_cache.delete(package_name):
- await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete()
+ await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete(package_name)
await ctx.send(f"Successfully cleared the cache for `{package_name}`.")
else:
await ctx.send("No keys matching the package found.")
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Clear scheduled inventories, queued symbols and cleanup task on cog unload."""
self.inventory_scheduler.cancel_all()
- self.init_refresh_task.cancel()
- scheduling.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear")
+ await self.item_fetcher.clear()
diff --git a/bot/exts/info/doc/_html.py b/bot/exts/info/doc/_html.py
index ca0a0ac4a..c101ec250 100644
--- a/bot/exts/info/doc/_html.py
+++ b/bot/exts/info/doc/_html.py
@@ -129,6 +129,9 @@ def get_signatures(start_signature: PageElement) -> List[str]:
start_signature,
*_find_next_siblings_until_tag(start_signature, ("dd",), limit=2),
)[-MAX_SIGNATURE_AMOUNT:]:
+ for tag in element.find_all("a", class_="headerlink", recursive=False):
+ tag.decompose()
+
signature = _UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
if signature:
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index 6ab38eb3d..8ce9ea3a1 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -255,4 +255,5 @@ def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> Optional[s
else:
signature = get_signatures(symbol_heading)
description = get_dd_description(symbol_heading)
- return _create_markdown(signature, description, symbol_data.url).replace("¶", "").strip()
+
+ return _create_markdown(signature, description, symbol_data.url).strip()
diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py
index 107f2344f..8e08e7ae4 100644
--- a/bot/exts/info/doc/_redis_cache.py
+++ b/bot/exts/info/doc/_redis_cache.py
@@ -1,22 +1,28 @@
from __future__ import annotations
import datetime
+import fnmatch
+import time
from typing import Optional, TYPE_CHECKING
from async_rediscache.types.base import RedisObject, namespace_lock
+from bot.log import get_logger
+
if TYPE_CHECKING:
from ._cog import DocItem
WEEK_SECONDS = datetime.timedelta(weeks=1).total_seconds()
+log = get_logger(__name__)
+
class DocRedisCache(RedisObject):
"""Interface for redis functionality needed by the Doc cog."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self._set_expires = set()
+ self._set_expires = dict[str, float]()
@namespace_lock
async def set(self, item: DocItem, value: str) -> None:
@@ -29,16 +35,30 @@ class DocRedisCache(RedisObject):
needs_expire = False
with await self._get_pool_connection() as connection:
- if redis_key not in self._set_expires:
+ set_expire = self._set_expires.get(redis_key)
+ if set_expire is None:
# An expire is only set if the key didn't exist before.
- # If this is the first time setting values for this key check if it exists and add it to
- # `_set_expires` to prevent redundant checks for subsequent uses with items from the same page.
- self._set_expires.add(redis_key)
- needs_expire = not await connection.exists(redis_key)
+ ttl = await connection.ttl(redis_key)
+ log.debug(f"Checked TTL for `{redis_key}`.")
+
+ if ttl == -1:
+ log.warning(f"Key `{redis_key}` had no expire set.")
+ if ttl < 0: # not set or didn't exist
+ needs_expire = True
+ else:
+ log.debug(f"Key `{redis_key}` has a {ttl} TTL.")
+ self._set_expires[redis_key] = time.monotonic() + ttl - .1 # we need this to expire before redis
+
+ elif time.monotonic() > set_expire:
+ # If we got here the key expired in redis and we can be sure it doesn't exist.
+ needs_expire = True
+ log.debug(f"Key `{redis_key}` expired in internal key cache.")
await connection.hset(redis_key, item.symbol_id, value)
if needs_expire:
+ self._set_expires[redis_key] = time.monotonic() + WEEK_SECONDS
await connection.expire(redis_key, WEEK_SECONDS)
+ log.info(f"Set {redis_key} to expire in a week.")
@namespace_lock
async def get(self, item: DocItem) -> Optional[str]:
@@ -49,12 +69,18 @@ class DocRedisCache(RedisObject):
@namespace_lock
async def delete(self, package: str) -> bool:
"""Remove all values for `package`; return True if at least one key was deleted, False otherwise."""
+ pattern = f"{self.namespace}:{package}:*"
+
with await self._get_pool_connection() as connection:
package_keys = [
- package_key async for package_key in connection.iscan(match=f"{self.namespace}:{package}:*")
+ package_key async for package_key in connection.iscan(match=pattern)
]
if package_keys:
await connection.delete(*package_keys)
+ log.info(f"Deleted keys from redis: {package_keys}.")
+ self._set_expires = {
+ key: expire for key, expire in self._set_expires.items() if not fnmatch.fnmatchcase(key, pattern)
+ }
return True
return False
diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py
index 06799fb71..34480db57 100644
--- a/bot/exts/info/help.py
+++ b/bot/exts/info/help.py
@@ -113,12 +113,28 @@ class CommandView(ui.View):
If the command has a parent, a button is added to the view to show that parent's help embed.
"""
- def __init__(self, help_command: CustomHelpCommand, command: Command):
+ def __init__(self, help_command: CustomHelpCommand, command: Command, context: Context):
+ self.context = context
super().__init__()
if command.parent:
self.children.append(GroupButton(help_command, command, emoji="↩️"))
+ async def interaction_check(self, interaction: Interaction) -> bool:
+ """
+ Ensures that the button only works for the user who spawned the help command.
+
+ Also allows moderators to access buttons even when not the author of message.
+ """
+ if interaction.user is not None:
+ if any(role.id in constants.MODERATION_ROLES for role in interaction.user.roles):
+ return True
+
+ elif interaction.user.id == self.context.author.id:
+ return True
+
+ return False
+
class GroupView(CommandView):
"""
@@ -130,8 +146,8 @@ class GroupView(CommandView):
MAX_BUTTONS_IN_ROW = 5
MAX_ROWS = 5
- def __init__(self, help_command: CustomHelpCommand, group: Group, subcommands: list[Command]):
- super().__init__(help_command, group)
+ def __init__(self, help_command: CustomHelpCommand, group: Group, subcommands: list[Command], context: Context):
+ super().__init__(help_command, group, context)
# Don't add buttons if only a portion of the subcommands can be shown.
if len(subcommands) + len(self.children) > self.MAX_ROWS * self.MAX_BUTTONS_IN_ROW:
log.trace(f"Attempted to add navigation buttons for `{group.qualified_name}`, but there was no space.")
@@ -302,7 +318,7 @@ class CustomHelpCommand(HelpCommand):
embed.description = command_details
# If the help is invoked in the context of an error, don't show subcommand navigation.
- view = CommandView(self, command) if not self.context.command_failed else None
+ view = CommandView(self, command, self.context) if not self.context.command_failed else None
return embed, view
async def send_command_help(self, command: Command) -> None:
@@ -347,7 +363,7 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n**Subcommands:**\n{command_details}"
# If the help is invoked in the context of an error, don't show subcommand navigation.
- view = GroupView(self, group, commands_) if not self.context.command_failed else None
+ view = GroupView(self, group, commands_, self.context) if not self.context.command_failed else None
return embed, view
async def send_group_help(self, group: Group) -> None:
@@ -473,12 +489,12 @@ class Help(Cog):
bot.help_command = CustomHelpCommand()
bot.help_command.cog = self
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Reset the help command when the cog is unloaded."""
self.bot.help_command = self.old_help_command
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Help cog."""
- bot.add_cog(Help(bot))
+ await bot.add_cog(Help(bot))
log.info("Cog loaded: Help")
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 73357211e..e7d17c971 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -6,22 +6,22 @@ from textwrap import shorten
from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union
import rapidfuzz
+from botcore.site_api import ResponseCodeError
from discord import AllowedMentions, Colour, Embed, Guild, Message, Role
from discord.ext.commands import BucketType, Cog, Context, Greedy, Paginator, command, group, has_any_role
from discord.utils import escape_markdown
from bot import constants
-from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.converters import MemberOrUser
from bot.decorators import in_whitelist
from bot.errors import NonExistentRoleError
from bot.log import get_logger
from bot.pagination import LinePaginator
+from bot.utils import time
from bot.utils.channel import is_mod_channel, is_staff_channel
from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta
log = get_logger(__name__)
@@ -83,7 +83,7 @@ class Information(Cog):
defcon_info = ""
if cog := self.bot.get_cog("Defcon"):
- threshold = humanize_delta(cog.threshold) if cog.threshold else "-"
+ threshold = time.humanize_delta(cog.threshold) if cog.threshold else "-"
defcon_info = f"Defcon threshold: {threshold}\n"
verification = f"Verification level: {ctx.guild.verification_level.name}\n"
@@ -173,15 +173,13 @@ class Information(Cog):
"""Returns an embed full of server information."""
embed = Embed(colour=Colour.og_blurple(), title="Server Information")
- created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE)
+ created = time.format_relative(ctx.guild.created_at)
num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone
# Server Features are only useful in certain channels
if ctx.channel.id in (
*constants.MODERATION_CHANNELS,
constants.Channels.dev_core,
- constants.Channels.dev_contrib,
- constants.Channels.bot_commands
):
features = f"\nFeatures: {', '.join(ctx.guild.features)}"
else:
@@ -227,7 +225,7 @@ class Information(Cog):
@command(name="user", aliases=["user_info", "member", "member_info", "u"])
async def user_info(self, ctx: Context, user_or_message: Union[MemberOrUser, Message] = None) -> None:
"""Returns info about a user."""
- if isinstance(user_or_message, Message):
+ if passed_as_message := isinstance(user_or_message, Message):
user = user_or_message.author
else:
user = user_or_message
@@ -242,20 +240,23 @@ class Information(Cog):
# Will redirect to #bot-commands if it fails.
if in_whitelist_check(ctx, roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES):
- embed = await self.create_user_embed(ctx, user)
+ embed = await self.create_user_embed(ctx, user, passed_as_message)
await ctx.send(embed=embed)
- async def create_user_embed(self, ctx: Context, user: MemberOrUser) -> Embed:
+ async def create_user_embed(self, ctx: Context, user: MemberOrUser, passed_as_message: bool) -> Embed:
"""Creates an embed containing information on the `user`."""
on_server = bool(await get_or_fetch_member(ctx.guild, user.id))
- created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE)
+ created = time.format_relative(user.created_at)
name = str(user)
if on_server and user.nick:
name = f"{user.nick} ({name})"
name = escape_markdown(name)
+ if passed_as_message:
+ name += " - From Message"
+
if user.public_flags.verified_bot:
name += f" {constants.Emojis.verified_bot}"
elif user.bot:
@@ -269,7 +270,7 @@ class Information(Cog):
if on_server:
if user.joined_at:
- joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE)
+ joined = time.format_relative(user.joined_at)
else:
joined = "Unable to get join date"
@@ -282,7 +283,6 @@ class Information(Cog):
membership = textwrap.dedent("\n".join([f"{key}: {value}" for key, value in membership.items()]))
else:
- roles = None
membership = "The user is not a member of the server"
fields = [
@@ -298,11 +298,11 @@ class Information(Cog):
"Member information",
membership
),
+ await self.user_messages(user),
]
# Show more verbose output in moderation channels for infractions and nominations
if is_mod_channel(ctx.channel):
- fields.append(await self.user_messages(user))
fields.append(await self.expanded_user_infraction_counts(user))
fields.append(await self.user_nomination_counts(user))
else:
@@ -420,13 +420,8 @@ class Information(Cog):
if e.status == 404:
activity_output = "No activity"
else:
- activity_output.append(user_activity["total_messages"] or "No messages")
-
- if (activity_blocks := user_activity.get("activity_blocks")) is not None:
- # activity_blocks is not included in the response if the user has a lot of messages
- activity_output.append(activity_blocks or "No activity") # Special case when activity_blocks is 0.
- else:
- activity_output.append("Too many to count!")
+ activity_output.append(f"{user_activity['total_messages']:,}" or "No messages")
+ activity_output.append(f"{user_activity['activity_blocks']:,}" or "No activity")
activity_output = "\n".join(
f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output)
@@ -475,7 +470,7 @@ class Information(Cog):
If `json` is True, send the information in a copy-pasteable Python format.
"""
- if ctx.author not in message.channel.members:
+ if not message.channel.permissions_for(ctx.author).read_messages:
await ctx.send(":x: You do not have permissions to see the channel this message is in.")
return
@@ -557,6 +552,6 @@ class Information(Cog):
await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Information cog."""
- bot.add_cog(Information(bot))
+ await bot.add_cog(Information(bot))
diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py
index 67866620b..d4df5d932 100644
--- a/bot/exts/info/pep.py
+++ b/bot/exts/info/pep.py
@@ -9,13 +9,12 @@ from discord.ext.commands import Cog, Context, command
from bot.bot import Bot
from bot.constants import Keys
from bot.log import get_logger
-from bot.utils import scheduling
from bot.utils.caching import AsyncCache
log = get_logger(__name__)
ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png"
-BASE_PEP_URL = "http://www.python.org/dev/peps/pep-"
+BASE_PEP_URL = "https://peps.python.org/pep-"
PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=main"
pep_cache = AsyncCache()
@@ -33,7 +32,10 @@ class PythonEnhancementProposals(Cog):
self.peps: Dict[int, str] = {}
# To avoid situations where we don't have last datetime, set this to now.
self.last_refreshed_peps: datetime = datetime.now()
- scheduling.create_task(self.refresh_peps_urls(), event_loop=self.bot.loop)
+
+ async def cog_load(self) -> None:
+ """Carry out cog asynchronous initialisation."""
+ await self.refresh_peps_urls()
async def refresh_peps_urls(self) -> None:
"""Refresh PEP URLs listing in every 3 hours."""
@@ -67,7 +69,7 @@ class PythonEnhancementProposals(Cog):
"""Get information embed about PEP 0."""
pep_embed = Embed(
title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**",
- url="https://www.python.org/dev/peps/"
+ url="https://peps.python.org/"
)
pep_embed.set_thumbnail(url=ICON_URL)
pep_embed.add_field(name="Status", value="Active")
@@ -163,6 +165,6 @@ class PythonEnhancementProposals(Cog):
log.trace(f"Getting PEP {pep_number} failed. Error embed sent.")
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the PEP cog."""
- bot.add_cog(PythonEnhancementProposals(bot))
+ await bot.add_cog(PythonEnhancementProposals(bot))
diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py
index dacf7bc12..2d387df3d 100644
--- a/bot/exts/info/pypi.py
+++ b/bot/exts/info/pypi.py
@@ -82,6 +82,6 @@ class PyPi(Cog):
await ctx.send(embed=embed)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the PyPi cog."""
- bot.add_cog(PyPi(bot))
+ await bot.add_cog(PyPi(bot))
diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py
index 2fad9d2ab..111b2dcaf 100644
--- a/bot/exts/info/python_news.py
+++ b/bot/exts/info/python_news.py
@@ -11,10 +11,9 @@ from discord.ext.tasks import loop
from bot import constants
from bot.bot import Bot
from bot.log import get_logger
-from bot.utils import scheduling
from bot.utils.webhooks import send_webhook
-PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/"
+PEPS_RSS_URL = "https://peps.python.org/peps.rss"
RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads"
THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/"
@@ -42,8 +41,10 @@ class PythonNews(Cog):
self.webhook_names = {}
self.webhook: t.Optional[discord.Webhook] = None
- scheduling.create_task(self.get_webhook_names(), event_loop=self.bot.loop)
- scheduling.create_task(self.get_webhook_and_channel(), event_loop=self.bot.loop)
+ async def cog_load(self) -> None:
+ """Carry out cog asynchronous initialisation."""
+ await self.get_webhook_names()
+ await self.get_webhook_and_channel()
async def start_tasks(self) -> None:
"""Start the tasks for fetching new PEPs and mailing list messages."""
@@ -240,11 +241,11 @@ class PythonNews(Cog):
await self.start_tasks()
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Stop news posting tasks on cog unload."""
self.fetch_new_media.cancel()
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Add `News` cog."""
- bot.add_cog(PythonNews(bot))
+ await bot.add_cog(PythonNews(bot))
diff --git a/bot/exts/info/resources.py b/bot/exts/info/resources.py
new file mode 100644
index 000000000..eeb9dd757
--- /dev/null
+++ b/bot/exts/info/resources.py
@@ -0,0 +1,70 @@
+import re
+from typing import Optional
+from urllib.parse import quote
+
+from discord import Embed
+from discord.ext import commands
+
+from bot.bot import Bot
+
+REGEX_CONSECUTIVE_NON_LETTERS = r"[^A-Za-z0-9]+"
+RESOURCE_URL = "https://www.pythondiscord.com/resources/"
+
+
+def to_kebabcase(resource_topic: str) -> str:
+ """
+ Convert any string to kebab-case.
+
+ For example, convert
+ "__Favorite FROOT¤#/$?is----LeMON???" to
+ "favorite-froot-is-lemon"
+
+ Code adopted from:
+ https://github.com/python-discord/site/blob/main/pydis_site/apps/resources/templatetags/to_kebabcase.py
+ """
+ # First, make it lowercase, and just remove any apostrophes.
+ # We remove the apostrophes because "wasnt" is better than "wasn-t"
+ resource_topic = resource_topic.casefold()
+ resource_topic = resource_topic.replace("'", '')
+
+ # Now, replace any non-alphanumerics that remains with a dash.
+ # If there are multiple consecutive non-letters, just replace them with a single dash.
+ # my-favorite-class is better than my-favorite------class
+ resource_topic = re.sub(
+ REGEX_CONSECUTIVE_NON_LETTERS,
+ "-",
+ resource_topic,
+ )
+
+ # Now we use strip to get rid of any leading or trailing dashes.
+ resource_topic = resource_topic.strip("-")
+ return resource_topic
+
+
+class Resources(commands.Cog):
+ """Display information about the Python Discord website Resource page."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @commands.command(name="resources", aliases=("res",))
+ async def resources_command(self, ctx: commands.Context, *, resource_topic: Optional[str]) -> None:
+ """Display information and a link to the Python Discord website Resources page."""
+ url = RESOURCE_URL
+
+ if resource_topic:
+ # Capture everything prior to new line allowing users to add messages below the command then prep for url
+ url = f"{url}?topics={quote(to_kebabcase(resource_topic.splitlines()[0]))}"
+
+ embed = Embed(
+ title="Resources",
+ description=f"The [Resources page]({url}) on our website contains a list "
+ f"of hand-selected learning resources that we "
+ f"regularly recommend to both beginners and experts."
+ )
+ await ctx.send(embed=embed)
+
+
+async def setup(bot: Bot) -> None:
+ """Load the Resources cog."""
+ await bot.add_cog(Resources(bot))
diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py
index e3e7029ca..f735cc744 100644
--- a/bot/exts/info/source.py
+++ b/bot/exts/info/source.py
@@ -98,6 +98,6 @@ class BotSource(commands.Cog):
return embed
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the BotSource cog."""
- bot.add_cog(BotSource(bot))
+ await bot.add_cog(BotSource(bot))
diff --git a/bot/exts/info/stats.py b/bot/exts/info/stats.py
index 4d8bb645e..d4001a7bb 100644
--- a/bot/exts/info/stats.py
+++ b/bot/exts/info/stats.py
@@ -85,11 +85,11 @@ class Stats(Cog):
self.bot.stats.gauge("boost.amount", g.premium_subscription_count)
self.bot.stats.gauge("boost.tier", g.premium_tier)
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Stop the boost statistic task on unload of the Cog."""
self.update_guild_boost.stop()
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the stats cog."""
- bot.add_cog(Stats(bot))
+ await bot.add_cog(Stats(bot))
diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py
index 1299d5d59..d37c97281 100644
--- a/bot/exts/info/subscribe.py
+++ b/bot/exts/info/subscribe.py
@@ -5,6 +5,7 @@ from dataclasses import dataclass
import arrow
import discord
+from botcore.utils import members
from discord.ext import commands
from discord.interactions import Interaction
@@ -12,7 +13,6 @@ from bot import constants
from bot.bot import Bot
from bot.decorators import redirect_output
from bot.log import get_logger
-from bot.utils import members, scheduling
@dataclass(frozen=True)
@@ -26,7 +26,7 @@ class AssignableRole:
role_id: int
months_available: t.Optional[tuple[int]]
- name: t.Optional[str] = None # This gets populated within Subscribe.init_cog()
+ name: t.Optional[str] = None # This gets populated within Subscribe.cog_load()
def is_currently_available(self) -> bool:
"""Check if the role is available for the current month."""
@@ -143,11 +143,10 @@ class Subscribe(commands.Cog):
def __init__(self, bot: Bot):
self.bot = bot
- self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop)
self.assignable_roles: list[AssignableRole] = []
self.guild: discord.Guild = None
- async def init_cog(self) -> None:
+ async def cog_load(self) -> None:
"""Initialise the cog by resolving the role IDs in ASSIGNABLE_ROLES to role names."""
await self.bot.wait_until_guild_available()
@@ -171,15 +170,13 @@ class Subscribe(commands.Cog):
self.assignable_roles.sort(key=operator.methodcaller("is_currently_available"), reverse=True)
@commands.cooldown(1, 10, commands.BucketType.member)
- @commands.command(name="subscribe")
+ @commands.command(name="subscribe", aliases=("unsubscribe",))
@redirect_output(
destination_channel=constants.Channels.bot_commands,
bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES,
)
async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args
"""Display the member's current state for each role, and allow them to add/remove the roles."""
- await self.init_task
-
button_view = RoleButtonView(ctx.author)
author_roles = [role.id for role in ctx.author.roles]
for index, role in enumerate(self.assignable_roles):
@@ -193,9 +190,9 @@ class Subscribe(commands.Cog):
)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Subscribe cog."""
if len(ASSIGNABLE_ROLES) > ITEMS_PER_ROW*5: # Discord limits views to 5 rows of buttons.
log.error("Too many roles for 5 rows, not loading the Subscribe cog.")
else:
- bot.add_cog(Subscribe(bot))
+ await bot.add_cog(Subscribe(bot))
diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py
index e5930a433..5d7467caf 100644
--- a/bot/exts/info/tags.py
+++ b/bot/exts/info/tags.py
@@ -275,6 +275,15 @@ class Tags(Cog):
]
tag = self.tags.get(tag_identifier)
+
+ if tag is None and tag_identifier.group is not None:
+ # Try exact match with only the name
+ name_only_identifier = TagIdentifier(None, tag_identifier.group)
+ tag = self.tags.get(name_only_identifier)
+ if tag:
+ # Ensure the correct tag information is sent to statsd
+ tag_identifier = name_only_identifier
+
if tag is None and len(filtered_tags) == 1:
tag_identifier = filtered_tags[0][0]
tag = filtered_tags[0][1]
@@ -390,6 +399,6 @@ class Tags(Cog):
return True
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Tags cog."""
- bot.add_cog(Tags(bot))
+ await bot.add_cog(Tags(bot))
diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py
index 826265aa3..39eff9757 100644
--- a/bot/exts/moderation/clean.py
+++ b/bot/exts/moderation/clean.py
@@ -1,14 +1,13 @@
import contextlib
-import logging
import re
import time
from collections import defaultdict
from contextlib import suppress
from datetime import datetime
-from itertools import islice
-from typing import Any, Callable, Iterable, Literal, Optional, TYPE_CHECKING, Union
+from itertools import takewhile
+from typing import Callable, Iterable, Literal, Optional, TYPE_CHECKING, Union
-from discord import Colour, Message, NotFound, TextChannel, User, errors
+from discord import Colour, Message, NotFound, TextChannel, Thread, User, errors
from discord.ext.commands import Cog, Context, Converter, Greedy, group, has_any_role
from discord.ext.commands.converter import TextChannelConverter
from discord.ext.commands.errors import BadArgument
@@ -17,12 +16,11 @@ from bot.bot import Bot
from bot.constants import Channels, CleanMessages, Colours, Emojis, Event, Icons, MODERATION_ROLES
from bot.converters import Age, ISODateTime
from bot.exts.moderation.modlog import ModLog
+from bot.log import get_logger
from bot.utils.channel import is_mod_channel
-log = logging.getLogger(__name__)
+log = get_logger(__name__)
-# Default number of messages to look at in each channel.
-DEFAULT_TRAVERSE = 10
# Number of seconds before command invocations and responses are deleted in non-moderation channels.
MESSAGE_DELETE_DELAY = 5
@@ -33,12 +31,12 @@ CleanLimit = Union[Message, Age, ISODateTime]
class CleanChannels(Converter):
- """A converter that turns the given string to a list of channels to clean, or the literal `*` for all channels."""
+ """A converter to turn the string into a list of channels to clean, or the literal `*` for all public channels."""
_channel_converter = TextChannelConverter()
async def convert(self, ctx: Context, argument: str) -> Union[Literal["*"], list[TextChannel]]:
- """Converts a string to a list of channels to clean, or the literal `*` for all channels."""
+ """Converts a string to a list of channels to clean, or the literal `*` for all public channels."""
if argument == "*":
return "*"
return [await self._channel_converter.convert(ctx, channel) for channel in argument.split()]
@@ -87,7 +85,6 @@ class Clean(Cog):
@staticmethod
def _validate_input(
- traverse: int,
channels: Optional[CleanChannels],
bots_only: bool,
users: Optional[list[User]],
@@ -95,9 +92,9 @@ class Clean(Cog):
second_limit: Optional[CleanLimit],
) -> None:
"""Raise errors if an argument value or a combination of values is invalid."""
- # Is this an acceptable amount of messages to traverse?
- if traverse > CleanMessages.message_limit:
- raise BadArgument(f"Cannot traverse more than {CleanMessages.message_limit} messages.")
+ if first_limit is None:
+ # This is an optional argument for the sake of the master command, but it's actually required.
+ raise BadArgument("Missing cleaning limit.")
if (isinstance(first_limit, Message) or isinstance(second_limit, Message)) and channels:
raise BadArgument("Both a message limit and channels specified.")
@@ -110,10 +107,6 @@ class Clean(Cog):
if users and bots_only:
raise BadArgument("Marked as bots only, but users were specified.")
- # This is an implementation error rather than user error.
- if second_limit and not first_limit:
- raise ValueError("Second limit specified without the first.")
-
@staticmethod
async def _send_expiring_message(ctx: Context, content: str) -> None:
"""Send `content` to the context channel. Automatically delete if it's not a mod channel."""
@@ -121,12 +114,39 @@ class Clean(Cog):
await ctx.send(content, delete_after=delete_after)
@staticmethod
+ def _channels_set(
+ channels: CleanChannels, ctx: Context, first_limit: CleanLimit, second_limit: CleanLimit
+ ) -> set[TextChannel]:
+ """Standardize the input `channels` argument to a usable set of text channels."""
+ # Default to using the invoking context's channel or the channel of the message limit(s).
+ if not channels:
+ # Input was validated - if first_limit is a message, second_limit won't point at a different channel.
+ if isinstance(first_limit, Message):
+ channels = {first_limit.channel}
+ elif isinstance(second_limit, Message):
+ channels = {second_limit.channel}
+ else:
+ channels = {ctx.channel}
+ else:
+ if channels == "*":
+ channels = {
+ channel for channel in ctx.guild.channels + ctx.guild.threads
+ if isinstance(channel, (TextChannel, Thread))
+ # Assume that non-public channels are not needed to optimize for speed.
+ and channel.permissions_for(ctx.guild.default_role).view_channel
+ }
+ else:
+ channels = set(channels)
+
+ return channels
+
+ @staticmethod
def _build_predicate(
+ first_limit: datetime,
+ second_limit: Optional[datetime] = None,
bots_only: bool = False,
users: Optional[list[User]] = None,
regex: Optional[re.Pattern] = None,
- first_limit: Optional[datetime] = None,
- second_limit: Optional[datetime] = None,
) -> Predicate:
"""Return the predicate that decides whether to delete a given message."""
def predicate_bots_only(message: Message) -> bool:
@@ -167,20 +187,18 @@ class Clean(Cog):
predicates = []
# Set up the correct predicate
+ if second_limit:
+ predicates.append(predicate_range) # Delete messages in the specified age range
+ else:
+ predicates.append(predicate_after) # Delete messages older than the specified age
+
if bots_only:
predicates.append(predicate_bots_only) # Delete messages from bots
if users:
predicates.append(predicate_specific_users) # Delete messages from specific user
if regex:
predicates.append(predicate_regex) # Delete messages that match regex
- # Add up to one of the following:
- if second_limit:
- predicates.append(predicate_range) # Delete messages in the specified age range
- elif first_limit:
- predicates.append(predicate_after) # Delete messages older than specific message
- if not predicates:
- return lambda m: True
if len(predicates) == 1:
return predicates[0]
return lambda m: all(pred(m) for pred in predicates)
@@ -195,16 +213,25 @@ class Clean(Cog):
# Invocation message has already been deleted
log.info("Tried to delete invocation message, but it was already deleted.")
- def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> tuple[defaultdict[Any, list], list[int]]:
+ def _use_cache(self, limit: datetime) -> bool:
+ """Tell whether all messages to be cleaned can be found in the cache."""
+ return self.bot.cached_messages[0].created_at <= limit
+
+ def _get_messages_from_cache(
+ self,
+ channels: set[TextChannel],
+ to_delete: Predicate,
+ lower_limit: datetime
+ ) -> tuple[defaultdict[TextChannel, list], list[int]]:
"""Helper function for getting messages from the cache."""
message_mappings = defaultdict(list)
message_ids = []
- for message in islice(self.bot.cached_messages, traverse):
+ for message in takewhile(lambda m: m.created_at > lower_limit, reversed(self.bot.cached_messages)):
if not self.cleaning:
# Cleaning was canceled
return message_mappings, message_ids
- if to_delete(message):
+ if message.channel in channels and to_delete(message):
message_mappings[message.channel].append(message)
message_ids.append(message.id)
@@ -212,17 +239,16 @@ class Clean(Cog):
async def _get_messages_from_channels(
self,
- traverse: int,
channels: Iterable[TextChannel],
to_delete: Predicate,
- before: Optional[datetime] = None,
+ before: datetime,
after: Optional[datetime] = None
- ) -> tuple[defaultdict[Any, list], list]:
+ ) -> tuple[defaultdict[TextChannel, list], list]:
message_mappings = defaultdict(list)
message_ids = []
for channel in channels:
- async for message in channel.history(limit=traverse, before=before, after=after):
+ async for message in channel.history(limit=CleanMessages.message_limit, before=before, after=after):
if not self.cleaning:
# Cleaning was canceled, return empty containers.
@@ -305,12 +331,17 @@ class Clean(Cog):
return deleted
- async def _modlog_cleaned_messages(self, messages: list[Message], channels: CleanChannels, ctx: Context) -> bool:
- """Log the deleted messages to the modlog. Return True if logging was successful."""
+ async def _modlog_cleaned_messages(
+ self,
+ messages: list[Message],
+ channels: CleanChannels,
+ ctx: Context
+ ) -> Optional[str]:
+ """Log the deleted messages to the modlog, returning the log url if logging was successful."""
if not messages:
# Can't build an embed, nothing to clean!
await self._send_expiring_message(ctx, ":x: No matching messages could be found.")
- return False
+ return None
# Reverse the list to have reverse chronological order
log_messages = reversed(messages)
@@ -318,7 +349,7 @@ class Clean(Cog):
# Build the embed and send it
if channels == "*":
- target_channels = "all channels"
+ target_channels = "all public channels"
else:
target_channels = ", ".join(channel.mention for channel in channels)
@@ -336,42 +367,33 @@ class Clean(Cog):
channel_id=Channels.mod_log,
)
- return True
+ return log_url
# endregion
async def _clean_messages(
self,
ctx: Context,
- traverse: int,
channels: Optional[CleanChannels],
bots_only: bool = False,
users: Optional[list[User]] = None,
regex: Optional[re.Pattern] = None,
first_limit: Optional[CleanLimit] = None,
second_limit: Optional[CleanLimit] = None,
- use_cache: Optional[bool] = True
- ) -> None:
- """A helper function that does the actual message cleaning."""
- self._validate_input(traverse, channels, bots_only, users, first_limit, second_limit)
+ attempt_delete_invocation: bool = True,
+ ) -> Optional[str]:
+ """A helper function that does the actual message cleaning, returns the log url if logging was successful."""
+ self._validate_input(channels, bots_only, users, first_limit, second_limit)
# Are we already performing a clean?
if self.cleaning:
await self._send_expiring_message(
ctx, ":x: Please wait for the currently ongoing clean operation to complete."
)
- return
+ return None
self.cleaning = True
- # Default to using the invoking context's channel or the channel of the message limit(s).
- if not channels:
- # Input was validated - if first_limit is a message, second_limit won't point at a different channel.
- if isinstance(first_limit, Message):
- channels = [first_limit.channel]
- elif isinstance(second_limit, Message):
- channels = [second_limit.channel]
- else:
- channels = [ctx.channel]
+ deletion_channels = self._channels_set(channels, ctx, first_limit, second_limit)
if isinstance(first_limit, Message):
first_limit = first_limit.created_at
@@ -381,19 +403,20 @@ class Clean(Cog):
first_limit, second_limit = sorted([first_limit, second_limit])
# Needs to be called after standardizing the input.
- predicate = self._build_predicate(bots_only, users, regex, first_limit, second_limit)
+ predicate = self._build_predicate(first_limit, second_limit, bots_only, users, regex)
- # Delete the invocation first
- await self._delete_invocation(ctx)
+ if attempt_delete_invocation:
+ # Delete the invocation first
+ await self._delete_invocation(ctx)
- if channels == "*" and use_cache:
- message_mappings, message_ids = self._get_messages_from_cache(traverse=traverse, to_delete=predicate)
+ if self._use_cache(first_limit):
+ log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in the cache.")
+ message_mappings, message_ids = self._get_messages_from_cache(
+ channels=deletion_channels, to_delete=predicate, lower_limit=first_limit
+ )
else:
- deletion_channels = channels
- if channels == "*":
- deletion_channels = [channel for channel in ctx.guild.channels if isinstance(channel, TextChannel)]
+ log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in channel histories.")
message_mappings, message_ids = await self._get_messages_from_channels(
- traverse=traverse,
channels=deletion_channels,
to_delete=predicate,
before=second_limit,
@@ -402,18 +425,30 @@ class Clean(Cog):
if not self.cleaning:
# Means that the cleaning was canceled
- return
+ return None
# Now let's delete the actual messages with purge.
self.mod_log.ignore(Event.message_delete, *message_ids)
deleted_messages = await self._delete_found(message_mappings)
self.cleaning = False
- logged = await self._modlog_cleaned_messages(deleted_messages, channels, ctx)
+ if not channels:
+ channels = deletion_channels
+ log_url = await self._modlog_cleaned_messages(deleted_messages, channels, ctx)
- if logged and is_mod_channel(ctx.channel):
- with suppress(NotFound): # Can happen if the invoker deleted their own messages.
- await ctx.message.add_reaction(Emojis.check_mark)
+ success_message = (
+ f"{Emojis.ok_hand} Deleted {len(deleted_messages)} messages. "
+ f"A log of the deleted messages can be found here {log_url}."
+ )
+ if log_url and is_mod_channel(ctx.channel):
+ try:
+ await ctx.reply(success_message)
+ except errors.HTTPException:
+ await ctx.send(success_message)
+ elif log_url:
+ if mods := self.bot.get_channel(Channels.mods):
+ await mods.send(f"{ctx.author.mention} {success_message}")
+ return log_url
# region: Commands
@@ -422,12 +457,10 @@ class Clean(Cog):
self,
ctx: Context,
users: Greedy[User] = None,
- traverse: Optional[int] = None,
first_limit: Optional[CleanLimit] = None,
second_limit: Optional[CleanLimit] = None,
- use_cache: Optional[bool] = None,
- bots_only: Optional[bool] = False,
regex: Optional[Regex] = None,
+ bots_only: Optional[bool] = False,
*,
channels: CleanChannels = None # "Optional" with discord.py silently ignores incorrect input.
) -> None:
@@ -437,91 +470,88 @@ class Clean(Cog):
If arguments are provided, will act as a master command from which all subcommands can be derived.
\u2003• `users`: A series of user mentions, ID's, or names.
- \u2003• `traverse`: The number of messages to look at in each channel. If using the cache, will look at the
- first `traverse` messages in the cache.
\u2003• `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime.
+ At least one limit is required.
If a message is provided, cleaning will happen in that channel, and channels cannot be provided.
- If a limit is provided, multiple channels cannot be provided.
If only one of them is provided, acts as `clean until`. If both are provided, acts as `clean between`.
- \u2003• `use_cache`: Whether to use the message cache.
- If not provided, will default to False unless an asterisk is used for the channels.
- \u2003• `bots_only`: Whether to delete only bots. If specified, users cannot be specified.
\u2003• `regex`: A regex pattern the message must contain to be deleted.
The pattern must be provided enclosed in backticks.
If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that.
- \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all channels.
+ \u2003• `bots_only`: Whether to delete only bots. If specified, users cannot be specified.
+ \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all public channels.
"""
- if not any([traverse, users, first_limit, second_limit, regex, channels]):
+ if not any([users, first_limit, second_limit, regex, channels]):
await ctx.send_help(ctx.command)
return
- if not traverse:
- if first_limit:
- traverse = CleanMessages.message_limit
- else:
- traverse = DEFAULT_TRAVERSE
- if use_cache is None:
- use_cache = channels == "*"
+ await self._clean_messages(ctx, channels, bots_only, users, regex, first_limit, second_limit)
- await self._clean_messages(
- ctx, traverse, channels, bots_only, users, regex, first_limit, second_limit, use_cache
- )
-
- @clean_group.command(name="user", aliases=["users"])
- async def clean_user(
+ @clean_group.command(name="users", aliases=["user"])
+ async def clean_users(
self,
ctx: Context,
- user: User,
- traverse: Optional[int] = DEFAULT_TRAVERSE,
- use_cache: Optional[bool] = True,
+ users: Greedy[User],
+ message_or_time: CleanLimit,
*,
channels: CleanChannels = None
) -> None:
- """Delete messages posted by the provided user, stop cleaning after traversing `traverse` messages."""
- await self._clean_messages(ctx, traverse, users=[user], channels=channels, use_cache=use_cache)
+ """
+ Delete messages posted by the provided users, stop cleaning after reaching `message_or_time`.
- @clean_group.command(name="all", aliases=["everything"])
- async def clean_all(
- self,
- ctx: Context,
- traverse: Optional[int] = DEFAULT_TRAVERSE,
- use_cache: Optional[bool] = True,
- *,
- channels: CleanChannels = None
- ) -> None:
- """Delete all messages, regardless of poster, stop cleaning after traversing `traverse` messages."""
- await self._clean_messages(ctx, traverse, channels=channels, use_cache=use_cache)
+ `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO
+ datetime.
+
+ If a message is specified the cleanup will be limited to the channel the message is in.
+
+ If a timedelta or an ISO datetime is specified, `channels` can be specified to clean across multiple channels.
+ An asterisk can also be used to designate cleanup across all channels.
+ """
+ await self._clean_messages(ctx, users=users, channels=channels, first_limit=message_or_time)
@clean_group.command(name="bots", aliases=["bot"])
- async def clean_bots(
- self,
- ctx: Context,
- traverse: Optional[int] = DEFAULT_TRAVERSE,
- use_cache: Optional[bool] = True,
- *,
- channels: CleanChannels = None
- ) -> None:
- """Delete all messages posted by a bot, stop cleaning after traversing `traverse` messages."""
- await self._clean_messages(ctx, traverse, bots_only=True, channels=channels, use_cache=use_cache)
+ async def clean_bots(self, ctx: Context, message_or_time: CleanLimit, *, channels: CleanChannels = None) -> None:
+ """
+ Delete all messages posted by a bot, stop cleaning after reaching `message_or_time`.
+
+ `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO
+ datetime.
+
+ If a message is specified the cleanup will be limited to the channel the message is in.
+
+ If a timedelta or an ISO datetime is specified, `channels` can be specified to clean across multiple channels.
+ An asterisk can also be used to designate cleanup across all channels.
+ """
+ await self._clean_messages(ctx, bots_only=True, channels=channels, first_limit=message_or_time)
@clean_group.command(name="regex", aliases=["word", "expression", "pattern"])
async def clean_regex(
self,
ctx: Context,
regex: Regex,
- traverse: Optional[int] = DEFAULT_TRAVERSE,
- use_cache: Optional[bool] = True,
+ message_or_time: CleanLimit,
*,
channels: CleanChannels = None
) -> None:
"""
- Delete all messages that match a certain regex, stop cleaning after traversing `traverse` messages.
+ Delete all messages that match a certain regex, stop cleaning after reaching `message_or_time`.
- The pattern must be provided enclosed in backticks.
- If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that.
- For example: `[0-9]`
+ `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO
+ datetime.
+
+ If a message is specified the cleanup will be limited to the channel the message is in.
+
+ If a timedelta or an ISO datetime is specified, `channels` can be specified to clean across multiple channels.
+ An asterisk can also be used to designate cleanup across all channels.
+
+ The `regex` pattern must be provided enclosed in backticks.
+
+ For example: \\`[0-9]\\`.
+
+ If the `regex` pattern contains spaces, it still needs to be enclosed in double quotes on top of that.
+
+ For example: "\\`[0-9]\\`".
"""
- await self._clean_messages(ctx, traverse, regex=regex, channels=channels, use_cache=use_cache)
+ await self._clean_messages(ctx, regex=regex, channels=channels, first_limit=message_or_time)
@clean_group.command(name="until")
async def clean_until(
@@ -534,11 +564,14 @@ class Clean(Cog):
Delete all messages until a certain limit.
A limit can be either a message, and ISO date-time string, or a time delta.
- If a message is specified, `channel` cannot be specified.
+
+ If a message is specified the cleanup will be limited to the channel the message is in.
+
+ If a timedelta or an ISO datetime is specified, `channels` can be specified to clean across multiple channels.
+ An asterisk can also be used to designate cleanup across all channels.
"""
await self._clean_messages(
ctx,
- CleanMessages.message_limit,
channels=[channel] if channel else None,
first_limit=until,
)
@@ -558,11 +591,13 @@ class Clean(Cog):
A limit can be either a message, and ISO date-time string, or a time delta.
If two messages are specified, they both must be in the same channel.
- If a message is specified, `channel` cannot be specified.
+ The cleanup will be limited to the channel the messages are in.
+
+ If two timedeltas or ISO datetimes are specified, `channels` can be specified to clean across multiple channels.
+ An asterisk can also be used to designate cleanup across all channels.
"""
await self._clean_messages(
ctx,
- CleanMessages.message_limit,
channels=[channel] if channel else None,
first_limit=first_limit,
second_limit=second_limit,
@@ -591,6 +626,6 @@ class Clean(Cog):
self.cleaning = False
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Clean cog."""
- bot.add_cog(Clean(bot))
+ await bot.add_cog(Clean(bot))
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index 14db37367..1df79149d 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -1,3 +1,4 @@
+import asyncio
import traceback
from collections import namedtuple
from datetime import datetime
@@ -7,6 +8,8 @@ from typing import Optional, Union
import arrow
from aioredis import RedisError
from async_rediscache import RedisCache
+from botcore.utils import scheduling
+from botcore.utils.scheduling import Scheduler
from dateutil.relativedelta import relativedelta
from discord import Colour, Embed, Forbidden, Member, TextChannel, User
from discord.ext import tasks
@@ -17,12 +20,8 @@ from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_RO
from bot.converters import DurationDelta, Expiry
from bot.exts.moderation.modlog import ModLog
from bot.log import get_logger
-from bot.utils import scheduling
+from bot.utils import time
from bot.utils.messages import format_user
-from bot.utils.scheduling import Scheduler
-from bot.utils.time import (
- TimestampFormats, discord_timestamp, humanize_delta, parse_duration_string, relativedelta_to_timedelta
-)
log = get_logger(__name__)
@@ -72,23 +71,25 @@ class Defcon(Cog):
scheduling.create_task(self._sync_settings(), event_loop=self.bot.loop)
- @property
- def mod_log(self) -> ModLog:
+ async def get_mod_log(self) -> ModLog:
"""Get currently loaded ModLog cog instance."""
- return self.bot.get_cog("ModLog")
+ while not (cog := self.bot.get_cog("ModLog")):
+ await asyncio.sleep(1)
+ return cog
@defcon_settings.atomic_transaction
async def _sync_settings(self) -> None:
"""On cog load, try to synchronize DEFCON settings to the API."""
log.trace("Waiting for the guild to become available before syncing.")
await self.bot.wait_until_guild_available()
+
self.channel = await self.bot.fetch_channel(Channels.defcon)
log.trace("Syncing settings.")
try:
settings = await self.defcon_settings.to_dict()
- self.threshold = parse_duration_string(settings["threshold"]) if settings.get("threshold") else None
+ self.threshold = time.parse_duration_string(settings["threshold"]) if settings.get("threshold") else None
self.expiry = datetime.fromisoformat(settings["expiry"]) if settings.get("expiry") else None
except RedisError:
log.exception("Unable to get DEFCON settings!")
@@ -102,9 +103,9 @@ class Defcon(Cog):
self.scheduler.schedule_at(self.expiry, 0, self._remove_threshold())
self._update_notifier()
- log.info(f"DEFCON synchronized: {humanize_delta(self.threshold) if self.threshold else '-'}")
+ log.info(f"DEFCON synchronized: {time.humanize_delta(self.threshold) if self.threshold else '-'}")
- self._update_channel_topic()
+ await self._update_channel_topic()
@Cog.listener()
async def on_member_join(self, member: Member) -> None:
@@ -112,7 +113,7 @@ class Defcon(Cog):
if self.threshold:
now = arrow.utcnow()
- if now - member.created_at < relativedelta_to_timedelta(self.threshold):
+ if now - member.created_at < time.relativedelta_to_timedelta(self.threshold):
log.info(f"Rejecting user {member}: Account is too new")
message_sent = False
@@ -136,7 +137,7 @@ class Defcon(Cog):
if not message_sent:
message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled."
- await self.mod_log.send_log_message(
+ await (await self.get_mod_log()).send_log_message(
Icons.defcon_denied, Colours.soft_red, "Entry denied",
message, member.display_avatar.url
)
@@ -151,11 +152,12 @@ class Defcon(Cog):
@has_any_role(*MODERATION_ROLES)
async def status(self, ctx: Context) -> None:
"""Check the current status of DEFCON mode."""
+ expiry = time.format_relative(self.expiry) if self.expiry else "-"
embed = Embed(
colour=Colour.og_blurple(), title="DEFCON Status",
description=f"""
- **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"}
- **Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"}
+ **Threshold:** {time.humanize_delta(self.threshold) if self.threshold else "-"}
+ **Expires:** {expiry}
**Verification level:** {ctx.guild.verification_level.name}
"""
)
@@ -211,11 +213,12 @@ class Defcon(Cog):
await role.edit(reason="DEFCON unshutdown", permissions=permissions)
await ctx.send(f"{Action.SERVER_OPEN.value.emoji} Server reopened.")
- def _update_channel_topic(self) -> None:
+ async def _update_channel_topic(self) -> None:
"""Update the #defcon channel topic with the current DEFCON status."""
- new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})"
+ threshold = time.humanize_delta(self.threshold) if self.threshold else '-'
+ new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {threshold})"
- self.mod_log.ignore(Event.guild_channel_update, Channels.defcon)
+ (await self.get_mod_log()).ignore(Event.guild_channel_update, Channels.defcon)
scheduling.create_task(self.channel.edit(topic=new_topic))
@defcon_settings.atomic_transaction
@@ -255,12 +258,12 @@ class Defcon(Cog):
expiry_message = ""
if expiry:
- activity_duration = relativedelta(expiry, arrow.utcnow().datetime)
- expiry_message = f" for the next {humanize_delta(activity_duration, max_units=2)}"
+ formatted_expiry = time.humanize_delta(expiry, max_units=2)
+ expiry_message = f" for the next {formatted_expiry}"
if self.threshold:
channel_message = (
- f"updated; accounts must be {humanize_delta(self.threshold)} "
+ f"updated; accounts must be {time.humanize_delta(self.threshold)} "
f"old to join the server{expiry_message}"
)
else:
@@ -274,7 +277,7 @@ class Defcon(Cog):
await channel.send(message)
await self._send_defcon_log(action, author)
- self._update_channel_topic()
+ await self._update_channel_topic()
self._log_threshold_stat(threshold)
@@ -290,7 +293,7 @@ class Defcon(Cog):
def _log_threshold_stat(self, threshold: relativedelta) -> None:
"""Adds the threshold to the bot stats in days."""
- threshold_days = relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY
+ threshold_days = time.relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY
self.bot.stats.gauge("defcon.threshold", threshold_days)
async def _send_defcon_log(self, action: Action, actor: User) -> None:
@@ -298,11 +301,11 @@ class Defcon(Cog):
info = action.value
log_msg: str = (
f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n"
- f"{info.template.format(threshold=(humanize_delta(self.threshold) if self.threshold else '-'))}"
+ f"{info.template.format(threshold=(time.humanize_delta(self.threshold) if self.threshold else '-'))}"
)
status_msg = f"DEFCON {action.name.lower()}"
- await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg)
+ await (await self.get_mod_log()).send_log_message(info.icon, info.color, status_msg, log_msg)
def _update_notifier(self) -> None:
"""Start or stop the notifier according to the DEFCON status."""
@@ -317,15 +320,15 @@ class Defcon(Cog):
@tasks.loop(hours=1)
async def defcon_notifier(self) -> None:
"""Routinely notify moderators that DEFCON is active."""
- await self.channel.send(f"Defcon is on and is set to {humanize_delta(self.threshold)}.")
+ await self.channel.send(f"Defcon is on and is set to {time.humanize_delta(self.threshold)}.")
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Cancel the notifer and threshold removal tasks when the cog unloads."""
log.trace("Cog unload: canceling defcon notifier task.")
self.defcon_notifier.cancel()
self.scheduler.cancel_all()
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Defcon cog."""
- bot.add_cog(Defcon(bot))
+ await bot.add_cog(Defcon(bot))
diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py
index 566422e29..bf0b96a58 100644
--- a/bot/exts/moderation/dm_relay.py
+++ b/bot/exts/moderation/dm_relay.py
@@ -5,7 +5,7 @@ from bot.bot import Bot
from bot.constants import Emojis, MODERATION_ROLES
from bot.log import get_logger
from bot.utils.channel import is_mod_channel
-from bot.utils.services import send_to_paste_service
+from bot.utils.services import PasteTooLongError, PasteUploadError, send_to_paste_service
log = get_logger(__name__)
@@ -53,14 +53,14 @@ class DMRelay(Cog):
f"User: {user} ({user.id})\n"
f"Channel ID: {user.dm_channel.id}\n\n"
)
+ try:
+ message = await send_to_paste_service(metadata + output, extension="txt")
+ except PasteTooLongError:
+ message = f"{Emojis.cross_mark} Too long to upload to paste service."
+ except PasteUploadError:
+ message = f"{Emojis.cross_mark} Failed to upload to paste service."
- paste_link = await send_to_paste_service(metadata + output, extension="txt")
-
- if paste_link is None:
- await ctx.send(f"{Emojis.cross_mark} Failed to upload output to hastebin.")
- return
-
- await ctx.send(paste_link)
+ await ctx.send(message)
async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog in mod channels."""
@@ -68,6 +68,6 @@ class DMRelay(Cog):
and is_mod_channel(ctx.channel))
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the DMRelay cog."""
- bot.add_cog(DMRelay(bot))
+ await bot.add_cog(DMRelay(bot))
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
index 77dfad255..155b123ca 100644
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -6,12 +6,12 @@ from typing import Optional
import discord
from async_rediscache import RedisCache
+from botcore.utils import scheduling
from discord.ext.commands import Cog, Context, MessageConverter, MessageNotFound
from bot.bot import Bot
from bot.constants import Channels, Colours, Emojis, Guild, Roles, Webhooks
from bot.log import get_logger
-from bot.utils import scheduling
from bot.utils.messages import format_user, sub_clyde
log = get_logger(__name__)
@@ -183,7 +183,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d
except MessageNotFound:
mod_logs_channel = ctx.bot.get_channel(Channels.mod_log)
- last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten()
+ last_100_logs: list[discord.Message] = [message async for message in mod_logs_channel.history(limit=100)]
for log_entry in last_100_logs:
if not log_entry.embeds:
@@ -229,6 +229,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d
),
timestamp=message.created_at
)
+ embed.set_author(name=message.author, icon_url=message.author.display_avatar.url)
embed.add_field(
name="Content",
value=shorten_text(message.content) if message.content else "[No Message Content]"
@@ -403,7 +404,7 @@ class Incidents(Cog):
def check(payload: discord.RawReactionActionEvent) -> bool:
return payload.message_id == incident.id
- coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout)
+ coroutine = self.bot.wait_for("raw_message_delete", check=check, timeout=timeout)
return scheduling.create_task(coroutine, event_loop=self.bot.loop)
async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None:
@@ -657,6 +658,6 @@ class Incidents(Cog):
log.trace("Successfully deleted discord links webhook message.")
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Incidents cog."""
- bot.add_cog(Incidents(bot))
+ await bot.add_cog(Incidents(bot))
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 762eb6afa..c7f03b2e9 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -6,17 +6,18 @@ from gettext import ngettext
import arrow
import dateutil.parser
import discord
+from botcore.site_api import ResponseCodeError
+from botcore.utils import scheduling
from discord.ext.commands import Context
from bot import constants
-from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Colours
from bot.converters import MemberOrUser
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.modlog import ModLog
from bot.log import get_logger
-from bot.utils import messages, scheduling, time
+from bot.utils import messages, time
from bot.utils.channel import is_mod_channel
log = get_logger(__name__)
@@ -28,10 +29,9 @@ class InfractionScheduler:
def __init__(self, bot: Bot, supported_infractions: t.Container[str]):
self.bot = bot
self.scheduler = scheduling.Scheduler(self.__class__.__name__)
+ self.supported_infractions = supported_infractions
- scheduling.create_task(self.reschedule_infractions(supported_infractions), event_loop=self.bot.loop)
-
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Cancel scheduled tasks."""
self.scheduler.cancel_all()
@@ -40,9 +40,10 @@ class InfractionScheduler:
"""Get the currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None:
+ async def cog_load(self) -> None:
"""Schedule expiration for previous infractions."""
await self.bot.wait_until_guild_available()
+ supported_infractions = self.supported_infractions
log.trace(f"Rescheduling infractions for {self.__class__.__name__}.")
@@ -71,7 +72,7 @@ class InfractionScheduler:
)
log.trace("Will reschedule remaining infractions at %s", next_reschedule_point)
- self.scheduler.schedule_at(next_reschedule_point, -1, self.reschedule_infractions(supported_infractions))
+ self.scheduler.schedule_at(next_reschedule_point, -1, self.cog_load())
log.trace("Done rescheduling")
@@ -136,7 +137,7 @@ class InfractionScheduler:
infr_type = infraction["type"]
icon = _utils.INFRACTION_ICONS[infr_type][0]
reason = infraction["reason"]
- expiry = time.format_infraction_with_duration(infraction["expires_at"])
+ expiry = time.format_with_duration(infraction["expires_at"])
id_ = infraction['id']
if user_reason is None:
@@ -166,13 +167,12 @@ class InfractionScheduler:
# apply kick/ban infractions first, this would mean that we'd make it
# impossible for us to deliver a DM. See python-discord/bot#982.
if not infraction["hidden"] and infr_type in {"ban", "kick"}:
- dm_result = f"{constants.Emojis.failmail} "
- dm_log_text = "\nDM: **Failed**"
-
- # Accordingly update whether the user was successfully notified via DM.
- if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon):
+ if await _utils.notify_infraction(infraction, user, user_reason):
dm_result = ":incoming_envelope: "
dm_log_text = "\nDM: Sent"
+ else:
+ dm_result = f"{constants.Emojis.failmail} "
+ dm_log_text = "\nDM: **Failed**"
end_msg = ""
if is_mod_channel(ctx.channel):
@@ -221,7 +221,7 @@ class InfractionScheduler:
failed = True
if failed:
- log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.")
+ log.trace(f"Trying to delete infraction {id_} from database because applying infraction failed.")
try:
await self.bot.api_client.delete(f"bot/infractions/{id_}")
except ResponseCodeError as e:
@@ -234,13 +234,12 @@ class InfractionScheduler:
# If we need to DM and haven't already tried to
if not infraction["hidden"] and infr_type not in {"ban", "kick"}:
- dm_result = f"{constants.Emojis.failmail} "
- dm_log_text = "\nDM: **Failed**"
-
- # Accordingly update whether the user was successfully notified via DM.
- if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon):
+ if await _utils.notify_infraction(infraction, user, user_reason):
dm_result = ":incoming_envelope: "
dm_log_text = "\nDM: Sent"
+ else:
+ dm_result = f"{constants.Emojis.failmail} "
+ dm_log_text = "\nDM: **Failed**"
# Send a confirmation message to the invoking context.
log.trace(f"Sending infraction #{id_} confirmation message.")
@@ -261,7 +260,7 @@ class InfractionScheduler:
{additional_info}
"""),
content=log_content,
- footer=f"ID {infraction['id']}"
+ footer=f"ID: {id_}"
)
log.info(f"Applied {purge}{infr_type} infraction #{id_} to {user}.")
@@ -377,20 +376,15 @@ class InfractionScheduler:
actor = infraction["actor"]
type_ = infraction["type"]
id_ = infraction["id"]
- inserted_at = infraction["inserted_at"]
- expiry = infraction["expires_at"]
log.info(f"Marking infraction #{id_} as inactive (expired).")
- expiry = dateutil.parser.isoparse(expiry) if expiry else None
- created = time.format_infraction_with_duration(inserted_at, expiry)
-
log_content = None
log_text = {
"Member": f"<@{user_id}>",
"Actor": f"<@{actor}>",
"Reason": infraction["reason"],
- "Created": created,
+ "Created": time.format_with_duration(infraction["inserted_at"], infraction["expires_at"]),
}
try:
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index bb3cc5380..3a2485ec2 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -1,14 +1,17 @@
import typing as t
from datetime import datetime
+import arrow
import discord
+from botcore.site_api import ResponseCodeError
from discord.ext.commands import Context
-from bot.api import ResponseCodeError
+import bot
from bot.constants import Colours, Icons
from bot.converters import MemberOrUser
from bot.errors import InvalidInfractedUserError
from bot.log import get_logger
+from bot.utils import time
log = get_logger(__name__)
@@ -20,7 +23,7 @@ INFRACTION_ICONS = {
"note": (Icons.user_warn, None),
"superstar": (Icons.superstarify, Icons.unsuperstarify),
"warning": (Icons.user_warn, None),
- "voice_ban": (Icons.voice_state_red, Icons.voice_state_green),
+ "voice_mute": (Icons.voice_state_red, Icons.voice_state_green),
}
RULES_URL = "https://pythondiscord.com/pages/rules"
@@ -42,6 +45,7 @@ LONGEST_EXTRAS = max(len(INFRACTION_APPEAL_SERVER_FOOTER), len(INFRACTION_APPEAL
INFRACTION_DESCRIPTION_TEMPLATE = (
"**Type:** {type}\n"
"**Expires:** {expires}\n"
+ "**Duration:** {duration}\n"
"**Reason:** {reason}\n"
)
@@ -78,7 +82,8 @@ async def post_infraction(
reason: str,
expires_at: datetime = None,
hidden: bool = False,
- active: bool = True
+ active: bool = True,
+ dm_sent: bool = False,
) -> t.Optional[dict]:
"""Posts an infraction to the API."""
if isinstance(user, (discord.Member, discord.User)) and user.bot:
@@ -93,7 +98,8 @@ async def post_infraction(
"reason": reason,
"type": infr_type,
"user": user.id,
- "active": active
+ "active": active,
+ "dm_sent": dm_sent
}
if expires_at:
payload['expires_at'] = expires_at.isoformat()
@@ -156,18 +162,44 @@ async def send_active_infraction_message(ctx: Context, infraction: Infraction) -
async def notify_infraction(
+ infraction: Infraction,
user: MemberOrUser,
- infr_type: str,
- expires_at: t.Optional[str] = None,
- reason: t.Optional[str] = None,
- icon_url: str = Icons.token_removed
+ reason: t.Optional[str] = None
) -> bool:
- """DM a user about their new infraction and return True if the DM is successful."""
+ """
+ DM a user about their new infraction and return True if the DM is successful.
+
+ `reason` can be used to override what is in `infraction`. Otherwise, this data will
+ be retrieved from `infraction`.
+ """
+ infr_id = infraction["id"]
+ infr_type = infraction["type"].replace("_", " ").title()
+ icon_url = INFRACTION_ICONS[infraction["type"]][0]
+
+ if infraction["expires_at"] is None:
+ expires_at = "Never"
+ duration = "Permanent"
+ else:
+ expiry = arrow.get(infraction["expires_at"])
+ expires_at = time.format_relative(expiry)
+ duration = time.humanize_delta(infraction["inserted_at"], expiry, max_units=2)
+
+ if infraction["active"]:
+ remaining = time.humanize_delta(expiry, arrow.utcnow(), max_units=2)
+ if duration != remaining:
+ duration += f" ({remaining} remaining)"
+ else:
+ expires_at += " (Inactive)"
+
log.trace(f"Sending {user} a DM about their {infr_type} infraction.")
+ if reason is None:
+ reason = infraction["reason"]
+
text = INFRACTION_DESCRIPTION_TEMPLATE.format(
type=infr_type.title(),
- expires=expires_at or "N/A",
+ expires=expires_at,
+ duration=duration,
reason=reason or "No reason provided."
)
@@ -175,7 +207,7 @@ async def notify_infraction(
if len(text) > 4096 - LONGEST_EXTRAS:
text = f"{text[:4093-LONGEST_EXTRAS]}..."
- text += INFRACTION_APPEAL_SERVER_FOOTER if infr_type.lower() == 'ban' else INFRACTION_APPEAL_MODMAIL_FOOTER
+ text += INFRACTION_APPEAL_SERVER_FOOTER if infraction["type"] == 'ban' else INFRACTION_APPEAL_MODMAIL_FOOTER
embed = discord.Embed(
description=text,
@@ -186,7 +218,15 @@ async def notify_infraction(
embed.title = INFRACTION_TITLE
embed.url = RULES_URL
- return await send_private_embed(user, embed)
+ dm_sent = await send_private_embed(user, embed)
+ if dm_sent:
+ await bot.instance.api_client.patch(
+ f"bot/infractions/{infr_id}",
+ json={"dm_sent": True}
+ )
+ log.debug(f"Update infraction #{infr_id} dm_sent field to true.")
+
+ return dm_sent
async def notify_pardon(
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index e495a94b3..46fd3381c 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -9,8 +9,8 @@ from discord.ext.commands import Context, command
from bot import constants
from bot.bot import Bot
from bot.constants import Event
-from bot.converters import Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser
-from bot.decorators import respect_role_hierarchy
+from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser
+from bot.decorators import ensure_future_timestamp, respect_role_hierarchy
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
from bot.log import get_logger
@@ -19,6 +19,11 @@ from bot.utils.messages import format_user
log = get_logger(__name__)
+if t.TYPE_CHECKING:
+ from bot.exts.moderation.clean import Clean
+ from bot.exts.moderation.infraction.management import ModManagement
+ from bot.exts.moderation.watchchannels.bigbrother import BigBrother
+
class Infractions(InfractionScheduler, commands.Cog):
"""Apply and pardon infractions on users for moderation purposes."""
@@ -27,7 +32,7 @@ class Infractions(InfractionScheduler, commands.Cog):
category_description = "Server moderation tools."
def __init__(self, bot: Bot):
- super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_ban"})
+ super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_mute"})
self.category = "Moderation"
self._muted_role = discord.Object(constants.Roles.muted)
@@ -76,6 +81,7 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_kick(ctx, user, reason)
@command()
+ @ensure_future_timestamp(timestamp_arg=3)
async def ban(
self,
ctx: Context,
@@ -91,8 +97,9 @@ class Infractions(InfractionScheduler, commands.Cog):
"""
await self.apply_ban(ctx, user, reason, expires_at=duration)
- @command(aliases=('pban',))
- async def purgeban(
+ @command(aliases=("cban", "purgeban", "pban"))
+ @ensure_future_timestamp(timestamp_arg=3)
+ async def cleanban(
self,
ctx: Context,
user: UnambiguousMemberOrUser,
@@ -101,14 +108,63 @@ class Infractions(InfractionScheduler, commands.Cog):
reason: t.Optional[str] = None
) -> None:
"""
- Same as ban but removes all their messages of the last 24 hours.
+ Same as ban, but also cleans all their messages from the last hour.
If duration is specified, it temporarily bans that user for the given duration.
"""
- await self.apply_ban(ctx, user, reason, 1, expires_at=duration)
+ clean_cog: t.Optional[Clean] = self.bot.get_cog("Clean")
+ if clean_cog is None:
+ # If we can't get the clean cog, fall back to native purgeban.
+ await self.apply_ban(ctx, user, reason, purge_days=1, expires_at=duration)
+ return
+
+ infraction = await self.apply_ban(ctx, user, reason, expires_at=duration)
+ if not infraction or not infraction.get("id"):
+ # Ban was unsuccessful, quit early.
+ await ctx.send(":x: Failed to apply ban.")
+ log.error("Failed to apply ban to user %d", user.id)
+ return
+
+ # Calling commands directly skips discord.py's convertors, so we need to convert args manually.
+ clean_time = await Age().convert(ctx, "1h")
+
+ log_url = await clean_cog._clean_messages(
+ ctx,
+ users=[user],
+ channels="*",
+ first_limit=clean_time,
+ attempt_delete_invocation=False,
+ )
+ if not log_url:
+ # Cleaning failed, or there were no messages to clean, exit early.
+ return
+
+ infr_manage_cog: t.Optional[ModManagement] = self.bot.get_cog("ModManagement")
+ if infr_manage_cog is None:
+ # If we can't get the mod management cog, don't bother appending the log.
+ return
+
+ # Overwrite the context's send function so infraction append
+ # doesn't output the update infraction confirmation message.
+ async def send(*args, **kwargs) -> None:
+ pass
+ ctx.send = send
+ await infr_manage_cog.infraction_append(ctx, infraction, None, reason=f"[Clean log]({log_url})")
- @command(aliases=('vban',))
- async def voiceban(
+ @command(aliases=("vban",))
+ async def voiceban(self, ctx: Context) -> None:
+ """
+ NOT IMPLEMENTED.
+
+ Permanently ban a user from joining voice channels.
+
+ If duration is specified, it temporarily voice bans that user for the given duration.
+ """
+ await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `voicemute`?")
+
+ @command(aliases=("vmute",))
+ @ensure_future_timestamp(timestamp_arg=3)
+ async def voicemute(
self,
ctx: Context,
user: UnambiguousMemberOrUser,
@@ -117,16 +173,17 @@ class Infractions(InfractionScheduler, commands.Cog):
reason: t.Optional[str]
) -> None:
"""
- Permanently ban user from using voice channels.
+ Permanently mute user in voice channels.
- If duration is specified, it temporarily voice bans that user for the given duration.
+ If duration is specified, it temporarily voice mutes that user for the given duration.
"""
- await self.apply_voice_ban(ctx, user, reason, expires_at=duration)
+ await self.apply_voice_mute(ctx, user, reason, expires_at=duration)
# endregion
# region: Temporary infractions
@command(aliases=["mute"])
+ @ensure_future_timestamp(timestamp_arg=3)
async def tempmute(
self, ctx: Context,
user: UnambiguousMemberOrUser,
@@ -160,6 +217,7 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_mute(ctx, user, reason, expires_at=duration)
@command(aliases=("tban",))
+ @ensure_future_timestamp(timestamp_arg=3)
async def tempban(
self,
ctx: Context,
@@ -186,16 +244,26 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_ban(ctx, user, reason, expires_at=duration)
@command(aliases=("tempvban", "tvban"))
- async def tempvoiceban(
- self,
- ctx: Context,
- user: UnambiguousMemberOrUser,
- duration: Expiry,
- *,
- reason: t.Optional[str]
+ async def tempvoiceban(self, ctx: Context) -> None:
+ """
+ NOT IMPLEMENTED.
+
+ Temporarily voice bans that user for the given duration.
+ """
+ await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `tempvoicemute`?")
+
+ @command(aliases=("tempvmute", "tvmute"))
+ @ensure_future_timestamp(timestamp_arg=3)
+ async def tempvoicemute(
+ self,
+ ctx: Context,
+ user: UnambiguousMemberOrUser,
+ duration: Expiry,
+ *,
+ reason: t.Optional[str]
) -> None:
"""
- Temporarily voice ban a user for the given reason and duration.
+ Temporarily voice mute a user for the given reason and duration.
A unit of time should be appended to the duration.
Units (∗case-sensitive):
@@ -209,7 +277,7 @@ class Infractions(InfractionScheduler, commands.Cog):
Alternatively, an ISO 8601 timestamp can be provided for the duration.
"""
- await self.apply_voice_ban(ctx, user, reason, expires_at=duration)
+ await self.apply_voice_mute(ctx, user, reason, expires_at=duration)
# endregion
# region: Permanent shadow infractions
@@ -232,6 +300,7 @@ class Infractions(InfractionScheduler, commands.Cog):
# region: Temporary shadow infractions
@command(hidden=True, aliases=["shadowtempban", "stempban", "stban"])
+ @ensure_future_timestamp(timestamp_arg=3)
async def shadow_tempban(
self,
ctx: Context,
@@ -271,9 +340,18 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.pardon_infraction(ctx, "ban", user)
@command(aliases=("uvban",))
- async def unvoiceban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None:
- """Prematurely end the active voice ban infraction for the user."""
- await self.pardon_infraction(ctx, "voice_ban", user)
+ async def unvoiceban(self, ctx: Context) -> None:
+ """
+ NOT IMPLEMENTED.
+
+ Temporarily voice bans that user for the given duration.
+ """
+ await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `unvoicemute`?")
+
+ @command(aliases=("uvmute",))
+ async def unvoicemute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None:
+ """Prematurely end the active voice mute infraction for the user."""
+ await self.pardon_infraction(ctx, "voice_mute", user)
# endregion
# region: Base apply functions
@@ -339,7 +417,7 @@ class Infractions(InfractionScheduler, commands.Cog):
reason: t.Optional[str],
purge_days: t.Optional[int] = 0,
**kwargs
- ) -> None:
+ ) -> t.Optional[dict]:
"""
Apply a ban infraction with kwargs passed to `post_infraction`.
@@ -347,7 +425,7 @@ class Infractions(InfractionScheduler, commands.Cog):
"""
if isinstance(user, Member) and user.top_role >= ctx.me.top_role:
await ctx.send(":x: I can't ban users above or equal to me in the role hierarchy.")
- return
+ return None
# In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active
is_temporary = kwargs.get("expires_at") is not None
@@ -356,19 +434,19 @@ class Infractions(InfractionScheduler, commands.Cog):
if active_infraction:
if is_temporary:
log.trace("Tempban ignored as it cannot overwrite an active ban.")
- return
+ return None
if active_infraction.get('expires_at') is None:
log.trace("Permaban already exists, notify.")
await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).")
- return
+ return None
log.trace("Old tempban is being replaced by new permaban.")
await self.pardon_infraction(ctx, "ban", user, send_msg=is_temporary)
infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)
if infraction is None:
- return
+ return None
infraction["purge"] = "purge " if purge_days else ""
@@ -380,27 +458,25 @@ class Infractions(InfractionScheduler, commands.Cog):
action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days)
await self.apply_infraction(ctx, infraction, user, action)
+ bb_cog: t.Optional[BigBrother] = self.bot.get_cog("Big Brother")
if infraction.get('expires_at') is not None:
log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.")
- return
-
- bb_cog = self.bot.get_cog("Big Brother")
- if not bb_cog:
+ elif not bb_cog:
log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.")
- return
-
- log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.")
+ else:
+ log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.")
+ bb_reason = "User has been permanently banned from the server. Automatically removed."
+ await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False)
- bb_reason = "User has been permanently banned from the server. Automatically removed."
- await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False)
+ return infraction
@respect_role_hierarchy(member_arg=2)
- async def apply_voice_ban(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None:
- """Apply a voice ban infraction with kwargs passed to `post_infraction`."""
- if await _utils.get_active_infraction(ctx, user, "voice_ban"):
+ async def apply_voice_mute(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None:
+ """Apply a voice mute infraction with kwargs passed to `post_infraction`."""
+ if await _utils.get_active_infraction(ctx, user, "voice_mute"):
return
- infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs)
+ infraction = await _utils.post_infraction(ctx, user, "voice_mute", reason, active=True, **kwargs)
if infraction is None:
return
@@ -414,7 +490,7 @@ class Infractions(InfractionScheduler, commands.Cog):
if not isinstance(user, Member):
return
- await user.move_to(None, reason="Disconnected from voice to apply voiceban.")
+ await user.move_to(None, reason="Disconnected from voice to apply voice mute.")
await user.remove_roles(self._voice_verified_role, reason=reason)
await self.apply_infraction(ctx, infraction, user, action())
@@ -471,7 +547,7 @@ class Infractions(InfractionScheduler, commands.Cog):
return log_text
- async def pardon_voice_ban(
+ async def pardon_voice_mute(
self,
user_id: int,
guild: discord.Guild,
@@ -487,9 +563,9 @@ class Infractions(InfractionScheduler, commands.Cog):
# DM user about infraction expiration
notified = await _utils.notify_pardon(
user=user,
- title="Voice ban ended",
- content="You have been unbanned and can verify yourself again in the server.",
- icon_url=_utils.INFRACTION_ICONS["voice_ban"][1]
+ title="Voice mute ended",
+ content="You have been unmuted and can verify yourself again in the server.",
+ icon_url=_utils.INFRACTION_ICONS["voice_mute"][1]
)
log_text["DM"] = "Sent" if notified else "**Failed**"
@@ -514,8 +590,8 @@ class Infractions(InfractionScheduler, commands.Cog):
return await self.pardon_mute(user_id, guild, reason, notify=notify)
elif infraction["type"] == "ban":
return await self.pardon_ban(user_id, guild, reason)
- elif infraction["type"] == "voice_ban":
- return await self.pardon_voice_ban(user_id, guild, notify=notify)
+ elif infraction["type"] == "voice_mute":
+ return await self.pardon_voice_mute(user_id, guild, notify=notify)
# endregion
@@ -533,6 +609,6 @@ class Infractions(InfractionScheduler, commands.Cog):
error.handled = True
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Infractions cog."""
- bot.add_cog(Infractions(bot))
+ await bot.add_cog(Infractions(bot))
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index a833eb227..a7d7a844a 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -1,18 +1,18 @@
+import re
import textwrap
import typing as t
-from datetime import datetime, timezone
-import dateutil.parser
import discord
-from dateutil.relativedelta import relativedelta
from discord.ext import commands
from discord.ext.commands import Context
from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings
+from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser
+from bot.decorators import ensure_future_timestamp
from bot.errors import InvalidInfraction
+from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction.infractions import Infractions
from bot.exts.moderation.modlog import ModLog
from bot.log import get_logger
@@ -20,7 +20,6 @@ from bot.pagination import LinePaginator
from bot.utils import messages, time
from bot.utils.channel import is_mod_channel
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import humanize_delta, until_expiration
log = get_logger(__name__)
@@ -43,12 +42,10 @@ class ModManagement(commands.Cog):
"""Get currently loaded Infractions cog instance."""
return self.bot.get_cog("Infractions")
- # region: Edit infraction commands
-
@commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True)
async def infraction_group(self, ctx: Context, infraction: Infraction = None) -> None:
"""
- Infraction manipulation commands.
+ Infraction management commands.
If `infraction` is passed then this command fetches that infraction. The `Infraction` converter
supports 'l', 'last' and 'recent' to get the most recent infraction made by `ctx.author`.
@@ -63,12 +60,36 @@ class ModManagement(commands.Cog):
)
await self.send_infraction_list(ctx, embed, [infraction])
+ @infraction_group.command(name="resend", aliases=("send", "rs", "dm"))
+ async def infraction_resend(self, ctx: Context, infraction: Infraction) -> None:
+ """Resend a DM to a user about a given infraction of theirs."""
+ if infraction["hidden"]:
+ await ctx.send(f"{constants.Emojis.failmail} You may not resend hidden infractions.")
+ return
+
+ member_id = infraction["user"]["id"]
+ member = await get_or_fetch_member(ctx.guild, member_id)
+ if not member:
+ await ctx.send(f"{constants.Emojis.failmail} Cannot find member `{member_id}` in the guild.")
+ return
+
+ id_ = infraction["id"]
+ reason = infraction["reason"] or "No reason provided."
+ reason += "\n\n**This is a re-sent message for a previously applied infraction which may have been edited.**"
+
+ if await _utils.notify_infraction(infraction, member, reason):
+ await ctx.send(f":incoming_envelope: Resent DM for infraction `{id_}`.")
+ else:
+ await ctx.send(f"{constants.Emojis.failmail} Failed to resend DM for infraction `{id_}`.")
+
+ # region: Edit infraction commands
+
@infraction_group.command(name="append", aliases=("amend", "add", "a"))
async def infraction_append(
self,
ctx: Context,
infraction: Infraction,
- duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821
+ duration: t.Union[Expiry, t.Literal["p", "permanent"], None],
*,
reason: str = None
) -> None:
@@ -103,11 +124,12 @@ class ModManagement(commands.Cog):
await self.infraction_edit(ctx, infraction, duration, reason=reason)
@infraction_group.command(name='edit', aliases=('e',))
+ @ensure_future_timestamp(timestamp_arg=3)
async def infraction_edit(
self,
ctx: Context,
infraction: Infraction,
- duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821
+ duration: t.Union[Expiry, t.Literal["p", "permanent"], None],
*,
reason: str = None
) -> None:
@@ -151,7 +173,7 @@ class ModManagement(commands.Cog):
confirm_messages.append("marked as permanent")
elif duration is not None:
request_data['expires_at'] = duration.isoformat()
- expiry = time.format_infraction_with_duration(request_data['expires_at'])
+ expiry = time.format_with_duration(duration)
confirm_messages.append(f"set to expire on {expiry}")
else:
confirm_messages.append("expiry unchanged")
@@ -176,15 +198,15 @@ class ModManagement(commands.Cog):
if 'expires_at' in request_data:
# A scheduled task should only exist if the old infraction wasn't permanent
if infraction['expires_at']:
- self.infractions_cog.scheduler.cancel(new_infraction['id'])
+ self.infractions_cog.scheduler.cancel(infraction_id)
# If the infraction was not marked as permanent, schedule a new expiration task
if request_data['expires_at']:
self.infractions_cog.schedule_expiration(new_infraction)
log_text += f"""
- Previous expiry: {until_expiration(infraction['expires_at']) or "Permanent"}
- New expiry: {until_expiration(new_infraction['expires_at']) or "Permanent"}
+ Previous expiry: {time.until_expiration(infraction['expires_at'])}
+ New expiry: {time.until_expiration(new_infraction['expires_at'])}
""".rstrip()
changes = ' & '.join(confirm_messages)
@@ -210,7 +232,8 @@ class ModManagement(commands.Cog):
Member: {user_text}
Actor: <@{new_infraction['actor']}>
Edited by: {ctx.message.author.mention}{log_text}
- """)
+ """),
+ footer=f"ID: {infraction_id}"
)
# endregion
@@ -253,6 +276,11 @@ class ModManagement(commands.Cog):
@infraction_search_group.command(name="reason", aliases=("match", "regex", "re"))
async def search_reason(self, ctx: Context, reason: str) -> None:
"""Search for infractions by their reason. Use Re2 for matching."""
+ try:
+ re.compile(reason)
+ except re.error as e:
+ raise commands.BadArgument(f"Invalid regular expression in `reason`: {e}")
+
infraction_list = await self.bot.api_client.get(
'bot/infractions/expanded',
params={'search': reason}
@@ -351,7 +379,9 @@ class ModManagement(commands.Cog):
active = infraction["active"]
user = infraction["user"]
expires_at = infraction["expires_at"]
- created = time.format_infraction(infraction["inserted_at"])
+ inserted_at = infraction["inserted_at"]
+ created = time.discord_timestamp(inserted_at)
+ dm_sent = infraction["dm_sent"]
# Format the user string.
if user_obj := self.bot.get_user(user["id"]):
@@ -363,25 +393,27 @@ class ModManagement(commands.Cog):
user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})"
if active:
- remaining = time.until_expiration(expires_at) or "Expired"
+ remaining = time.until_expiration(expires_at)
else:
remaining = "Inactive"
if expires_at is None:
duration = "*Permanent*"
else:
- date_from = datetime.fromtimestamp(
- float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1)),
- timezone.utc
- )
- date_to = dateutil.parser.isoparse(expires_at)
- duration = humanize_delta(relativedelta(date_to, date_from))
+ duration = time.humanize_delta(inserted_at, expires_at)
+
+ # Format `dm_sent`
+ if dm_sent is None:
+ dm_sent_text = "N/A"
+ else:
+ dm_sent_text = "Yes" if dm_sent else "No"
lines = textwrap.dedent(f"""
{"**===============**" if active else "==============="}
Status: {"__**Active**__" if active else "Inactive"}
User: {user_str}
Type: **{infraction["type"]}**
+ DM Sent: {dm_sent_text}
Shadow: {infraction["hidden"]}
Created: {created}
Expires: {remaining}
@@ -421,6 +453,6 @@ class ModManagement(commands.Cog):
error.handled = True
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the ModManagement cog."""
- bot.add_cog(ModManagement(bot))
+ await bot.add_cog(ModManagement(bot))
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 08c92b8f3..0e6aaa1e7 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -11,12 +11,13 @@ from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
from bot.converters import Duration, Expiry
+from bot.decorators import ensure_future_timestamp
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
from bot.log import get_logger
+from bot.utils import time
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import format_user
-from bot.utils.time import format_infraction
log = get_logger(__name__)
NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
@@ -57,32 +58,28 @@ class Superstarify(InfractionScheduler, Cog):
return
infraction = active_superstarifies[0]
- forced_nick = self.get_nick(infraction["id"], before.id)
+ infr_id = infraction["id"]
+
+ forced_nick = self.get_nick(infr_id, before.id)
if after.display_name == forced_nick:
return # Nick change was triggered by this event. Ignore.
+ reason = (
+ "You have tried to change your nickname on the **Python Discord** server "
+ f"from **{before.display_name}** to **{after.display_name}**, but as you "
+ "are currently in superstar-prison, you do not have permission to do so."
+ )
+
log.info(
f"{after.display_name} ({after.id}) tried to escape superstar prison. "
f"Changing the nick back to {before.display_name}."
)
await after.edit(
nick=forced_nick,
- reason=f"Superstarified member tried to escape the prison: {infraction['id']}"
- )
-
- notified = await _utils.notify_infraction(
- user=after,
- infr_type="Superstarify",
- expires_at=format_infraction(infraction["expires_at"]),
- reason=(
- "You have tried to change your nickname on the **Python Discord** server "
- f"from **{before.display_name}** to **{after.display_name}**, but as you "
- "are currently in superstar-prison, you do not have permission to do so."
- ),
- icon_url=_utils.INFRACTION_ICONS["superstar"][0]
+ reason=f"Superstarified member tried to escape the prison: {infr_id}"
)
- if not notified:
+ if not await _utils.notify_infraction(infraction, after, reason):
log.info("Failed to DM user about why they cannot change their nickname.")
@Cog.listener()
@@ -107,6 +104,7 @@ class Superstarify(InfractionScheduler, Cog):
await self.reapply_infraction(infraction, action)
@command(name="superstarify", aliases=("force_nick", "star", "starify", "superstar"))
+ @ensure_future_timestamp(timestamp_arg=3)
async def superstarify(
self,
ctx: Context,
@@ -150,7 +148,7 @@ class Superstarify(InfractionScheduler, Cog):
id_ = infraction["id"]
forced_nick = self.get_nick(id_, member.id)
- expiry_str = format_infraction(infraction["expires_at"])
+ expiry_str = time.discord_timestamp(infraction["expires_at"])
# Apply the infraction
async def action() -> None:
@@ -241,6 +239,6 @@ class Superstarify(InfractionScheduler, Cog):
return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Superstarify cog."""
- bot.add_cog(Superstarify(bot))
+ await bot.add_cog(Superstarify(bot))
diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py
index ce9c220b3..e70e60a20 100644
--- a/bot/exts/moderation/metabase.py
+++ b/bot/exts/moderation/metabase.py
@@ -2,21 +2,21 @@ import csv
import json
from datetime import timedelta
from io import StringIO
-from typing import Dict, List, Optional
+from typing import Dict, List, Literal, Optional
import arrow
from aiohttp.client_exceptions import ClientResponseError
from arrow import Arrow
from async_rediscache import RedisCache
+from botcore.utils.scheduling import Scheduler
from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
from bot.constants import Metabase as MetabaseConfig, Roles
-from bot.converters import allowed_strings
from bot.log import get_logger
-from bot.utils import scheduling, send_to_paste_service
+from bot.utils import send_to_paste_service
from bot.utils.channel import is_mod_channel
-from bot.utils.scheduling import Scheduler
+from bot.utils.services import PasteTooLongError, PasteUploadError
log = get_logger(__name__)
@@ -40,11 +40,9 @@ class Metabase(Cog):
self.exports: Dict[int, List[Dict]] = {} # Saves the output of each question, so internal eval can access it
- self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop)
-
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
"""Handle ClientResponseError errors locally to invalidate token if needed."""
- if not isinstance(error.original, ClientResponseError):
+ if not hasattr(error, "original") or not isinstance(error.original, ClientResponseError):
return
if error.original.status == 403:
@@ -61,7 +59,7 @@ class Metabase(Cog):
await ctx.send(f":x: {ctx.author.mention} Session token is invalid or refresh failed.")
error.handled = True
- async def init_cog(self) -> None:
+ async def cog_load(self) -> None:
"""Initialise the metabase session."""
expiry_time = await self.session_info.get("session_expiry")
if expiry_time:
@@ -110,7 +108,7 @@ class Metabase(Cog):
self,
ctx: Context,
question_id: int,
- extension: allowed_strings("csv", "json") = "csv"
+ extension: Literal["csv", "json"] = "csv"
) -> None:
"""
Extract data from a metabase question.
@@ -127,9 +125,6 @@ class Metabase(Cog):
"""
await ctx.trigger_typing()
- # Make sure we have a session token before running anything
- await self.init_task
-
url = f"{MetabaseConfig.base_url}/api/card/{question_id}/query/{extension}"
async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp:
@@ -146,11 +141,15 @@ class Metabase(Cog):
# Format it nicely for human eyes
out = json.dumps(out, indent=4, sort_keys=True)
- paste_link = await send_to_paste_service(out, extension=extension)
- if paste_link:
- message = f":+1: {ctx.author.mention} Here's your link: {paste_link}"
+ try:
+ paste_link = await send_to_paste_service(out, extension=extension)
+ except PasteTooLongError:
+ message = f":x: {ctx.author.mention} Too long to upload to paste service."
+ except PasteUploadError:
+ message = f":x: {ctx.author.mention} Failed to upload to paste service."
else:
- message = f":x: {ctx.author.mention} Link service is unavailible."
+ message = f":+1: {ctx.author.mention} Here's your link: {paste_link}"
+
await ctx.send(
f"{message}\nYou can also access this data within internal eval by doing: "
f"`bot.get_cog('Metabase').exports[{question_id}]`"
@@ -160,8 +159,6 @@ class Metabase(Cog):
async def metabase_publish(self, ctx: Context, question_id: int) -> None:
"""Publically shares the given question and posts the link."""
await ctx.trigger_typing()
- # Make sure we have a session token before running anything
- await self.init_task
url = f"{MetabaseConfig.base_url}/api/card/{question_id}/public_link"
@@ -179,22 +176,14 @@ class Metabase(Cog):
]
return all(checks)
- def cog_unload(self) -> None:
- """
- Cancel the init task and scheduled tasks.
-
- It's important to wait for init_task to be cancelled before cancelling scheduled
- tasks. Otherwise, it's possible for _session_scheduler to schedule another task
- after cancel_all has finished, despite _init_task.cancel being called first.
- This is cause cancel() on its own doesn't block until the task is cancelled.
- """
- self.init_task.cancel()
- self.init_task.add_done_callback(lambda _: self._session_scheduler.cancel_all())
+ async def cog_unload(self) -> None:
+ """Cancel all scheduled tasks."""
+ self._session_scheduler.cancel_all()
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Metabase cog."""
if not all((MetabaseConfig.username, MetabaseConfig.password)):
log.error("Credentials not provided, cog not loaded.")
return
- bot.add_cog(Metabase(bot))
+ await bot.add_cog(Metabase(bot))
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index 91709e5e5..67991730e 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -6,18 +6,20 @@ from datetime import datetime, timezone
from itertools import zip_longest
import discord
+from botcore.site_api import ResponseCodeError
from dateutil.relativedelta import relativedelta
from deepdiff import DeepDiff
from discord import Colour, Message, Thread
from discord.abc import GuildChannel
from discord.ext.commands import Cog, Context
-from discord.utils import escape_markdown
+from discord.utils import escape_markdown, format_dt, snowflake_time
+from sentry_sdk import add_breadcrumb
from bot.bot import Bot
from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs
from bot.log import get_logger
+from bot.utils import time
from bot.utils.messages import format_user
-from bot.utils.time import humanize_delta
log = get_logger(__name__)
@@ -53,24 +55,35 @@ class ModLog(Cog, name="ModLog"):
if attachments is None:
attachments = []
- response = await self.bot.api_client.post(
- 'bot/deleted-messages',
- json={
- 'actor': actor_id,
- 'creation': datetime.now(timezone.utc).isoformat(),
- 'deletedmessage_set': [
- {
- 'id': message.id,
- 'author': message.author.id,
- 'channel_id': message.channel.id,
- 'content': message.content.replace("\0", ""), # Null chars cause 400.
- 'embeds': [embed.to_dict() for embed in message.embeds],
- 'attachments': attachment,
- }
- for message, attachment in zip_longest(messages, attachments, fillvalue=[])
- ]
+ deletedmessage_set = [
+ {
+ "id": message.id,
+ "author": message.author.id,
+ "channel_id": message.channel.id,
+ "content": message.content.replace("\0", ""), # Null chars cause 400.
+ "embeds": [embed.to_dict() for embed in message.embeds],
+ "attachments": attachment,
}
- )
+ for message, attachment in zip_longest(messages, attachments, fillvalue=[])
+ ]
+
+ try:
+ response = await self.bot.api_client.post(
+ "bot/deleted-messages",
+ json={
+ "actor": actor_id,
+ "creation": datetime.now(timezone.utc).isoformat(),
+ "deletedmessage_set": deletedmessage_set,
+ }
+ )
+ except ResponseCodeError as e:
+ add_breadcrumb(
+ category="api_error",
+ message=str(e),
+ level="error",
+ data=deletedmessage_set,
+ )
+ raise
return f"{URLs.site_logs_view}/{response['id']}"
@@ -96,6 +109,7 @@ class ModLog(Cog, name="ModLog"):
footer: t.Optional[str] = None,
) -> Context:
"""Generate log embed and send to logging channel."""
+ await self.bot.wait_until_guild_available()
# Truncate string directly here to avoid removing newlines
embed = discord.Embed(
description=text[:4093] + "..." if len(text) > 4096 else text
@@ -115,7 +129,7 @@ class ModLog(Cog, name="ModLog"):
if ping_everyone:
if content:
- content = f"<@&{Roles.moderators}>\n{content}"
+ content = f"<@&{Roles.moderators}> {content}"
else:
content = f"<@&{Roles.moderators}>"
@@ -406,7 +420,7 @@ class ModLog(Cog, name="ModLog"):
now = datetime.now(timezone.utc)
difference = abs(relativedelta(now, member.created_at))
- message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference)
+ message = format_user(member) + "\n\n**Account age:** " + time.humanize_delta(difference)
if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account!
message = f"{Emojis.new} {message}"
@@ -572,6 +586,7 @@ class ModLog(Cog, name="ModLog"):
f"**Author:** {format_user(author)}\n"
f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
+ f"**Sent at:** {format_dt(message.created_at)}\n"
f"[Jump to message]({message.jump_url})\n"
"\n"
)
@@ -580,6 +595,7 @@ class ModLog(Cog, name="ModLog"):
f"**Author:** {format_user(author)}\n"
f"**Channel:** #{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
+ f"**Sent at:** {format_dt(message.created_at)}\n"
f"[Jump to message]({message.jump_url})\n"
"\n"
)
@@ -614,6 +630,7 @@ class ModLog(Cog, name="ModLog"):
This is called when a message absent from the cache is deleted.
Hence, the message contents aren't logged.
"""
+ await self.bot.wait_until_guild_available()
if self.is_channel_ignored(event.channel_id):
return
@@ -627,6 +644,7 @@ class ModLog(Cog, name="ModLog"):
response = (
f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{event.message_id}`\n"
+ f"**Sent at:** {format_dt(snowflake_time(event.message_id))}\n"
"\n"
"This message was not cached, so the message content cannot be displayed."
)
@@ -634,6 +652,7 @@ class ModLog(Cog, name="ModLog"):
response = (
f"**Channel:** #{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{event.message_id}`\n"
+ f"**Sent at:** {format_dt(snowflake_time(event.message_id))}\n"
"\n"
"This message was not cached, so the message content cannot be displayed."
)
@@ -711,7 +730,7 @@ class ModLog(Cog, name="ModLog"):
# datetime as the baseline and create a human-readable delta between this edit event
# and the last time the message was edited
timestamp = msg_before.edited_at
- delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at))
+ delta = time.humanize_delta(msg_after.edited_at, msg_before.edited_at)
footer = f"Last edited {delta} ago"
else:
# Message was not previously edited, use the created_at datetime as the baseline, no
@@ -727,6 +746,10 @@ class ModLog(Cog, name="ModLog"):
@Cog.listener()
async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None:
"""Log raw message edit event to message change log."""
+ if event.guild_id is None:
+ return # ignore DM edits
+
+ await self.bot.wait_until_guild_available()
try:
channel = self.bot.get_channel(int(event.data["channel_id"]))
message = await channel.fetch_message(event.message_id)
@@ -830,13 +853,8 @@ class ModLog(Cog, name="ModLog"):
)
@Cog.listener()
- async def on_thread_join(self, thread: Thread) -> None:
+ async def on_thread_create(self, thread: Thread) -> None:
"""Log thread creation."""
- # If we are in the thread already we can most probably assume we already logged it?
- # We don't really have a better way of doing this since the API doesn't make any difference between the two
- if thread.me:
- return
-
if self.is_channel_ignored(thread.id):
log.trace("Ignoring creation of thread %s (%d)", thread.mention, thread.id)
return
@@ -926,6 +944,6 @@ class ModLog(Cog, name="ModLog"):
)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the ModLog cog."""
- bot.add_cog(ModLog(bot))
+ await bot.add_cog(ModLog(bot))
diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py
index 20a8c39d7..bdefea91b 100644
--- a/bot/exts/moderation/modpings.py
+++ b/bot/exts/moderation/modpings.py
@@ -3,6 +3,7 @@ import datetime
import arrow
from async_rediscache import RedisCache
+from botcore.utils.scheduling import Scheduler
from dateutil.parser import isoparse, parse as dateutil_parse
from discord import Embed, Member
from discord.ext.commands import Cog, Context, group, has_any_role
@@ -11,9 +12,8 @@ from bot.bot import Bot
from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles
from bot.converters import Expiry
from bot.log import get_logger
-from bot.utils import scheduling
-from bot.utils.scheduling import Scheduler
-from bot.utils.time import TimestampFormats, discord_timestamp
+from bot.utils import time
+from bot.utils.members import get_or_fetch_member
log = get_logger(__name__)
@@ -41,15 +41,10 @@ class ModPings(Cog):
self.guild = None
self.moderators_role = None
- self.modpings_schedule_task = scheduling.create_task(
- self.reschedule_modpings_schedule(),
- event_loop=self.bot.loop
- )
- self.reschedule_task = scheduling.create_task(
- self.reschedule_roles(),
- name="mod-pings-reschedule",
- event_loop=self.bot.loop,
- )
+ async def cog_load(self) -> None:
+ """Schedule both when to reapply role and all mod ping schedules."""
+ # await self.reschedule_modpings_schedule()
+ await self.reschedule_roles()
async def reschedule_roles(self) -> None:
"""Reschedule moderators role re-apply times."""
@@ -63,18 +58,29 @@ class ModPings(Cog):
log.trace("Applying the moderators role to the mod team where necessary.")
for mod in mod_team.members:
- if mod in pings_on: # Make sure that on-duty mods aren't in the cache.
+ if mod in pings_on: # Make sure that on-duty mods aren't in the redis cache.
if mod.id in pings_off:
await self.pings_off_mods.delete(mod.id)
continue
- # Keep the role off only for those in the cache.
+ # Keep the role off only for those in the redis cache.
if mod.id not in pings_off:
await self.reapply_role(mod)
else:
expiry = isoparse(pings_off[mod.id])
self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod))
+ # At this stage every entry in `pings_off` is expected to have a scheduled task, but that might not be the case
+ # if the discord.py cache is missing members, or if the ID belongs to a former moderator.
+ for mod_id, expiry_iso in pings_off.items():
+ if mod_id not in self._role_scheduler:
+ mod = await get_or_fetch_member(self.guild, mod_id)
+ # Make sure the member is still a moderator and doesn't have the pingable role.
+ if mod is None or mod.get_role(Roles.mod_team) is None or mod.get_role(Roles.moderators) is not None:
+ await self.pings_off_mods.delete(mod_id)
+ else:
+ self._role_scheduler.schedule_at(isoparse(expiry_iso), mod_id, self.reapply_role(mod))
+
async def reschedule_modpings_schedule(self) -> None:
"""Reschedule moderators schedule ping."""
await self.bot.wait_until_guild_available()
@@ -233,8 +239,8 @@ class ModPings(Cog):
await ctx.send(
f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from "
- f"{discord_timestamp(start, TimestampFormats.TIME)} to "
- f"{discord_timestamp(end, TimestampFormats.TIME)}!"
+ f"{time.discord_timestamp(start, time.TimestampFormats.TIME)} to "
+ f"{time.discord_timestamp(end, time.TimestampFormats.TIME)}!"
)
@schedule_modpings.command(name='delete', aliases=('del', 'd'))
@@ -244,16 +250,13 @@ class ModPings(Cog):
await self.modpings_schedule.delete(ctx.author.id)
await ctx.send(f"{Emojis.ok_hand} {ctx.author.mention} Deleted your modpings schedule!")
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Cancel role tasks when the cog unloads."""
- log.trace("Cog unload: canceling role tasks.")
- self.reschedule_task.cancel()
+ log.trace("Cog unload: cancelling all scheduled tasks.")
self._role_scheduler.cancel_all()
-
- self.modpings_schedule_task.cancel()
self._modpings_scheduler.cancel_all()
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the ModPings cog."""
- bot.add_cog(ModPings(bot))
+ await bot.add_cog(ModPings(bot))
diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py
index 511520252..578551d24 100644
--- a/bot/exts/moderation/silence.py
+++ b/bot/exts/moderation/silence.py
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
from typing import Optional, OrderedDict, Union
from async_rediscache import RedisCache
+from botcore.utils.scheduling import Scheduler
from discord import Guild, PermissionOverwrite, TextChannel, Thread, VoiceChannel
from discord.ext import commands, tasks
from discord.ext.commands import Context
@@ -14,9 +15,7 @@ from bot import constants
from bot.bot import Bot
from bot.converters import HushDurationConverter
from bot.log import get_logger
-from bot.utils import scheduling
from bot.utils.lock import LockedResourceError, lock, lock_arg
-from bot.utils.scheduling import Scheduler
log = get_logger(__name__)
@@ -56,7 +55,6 @@ class SilenceNotifier(tasks.Loop):
hours=0,
count=None,
reconnect=True,
- loop=None,
time=MISSING
)
self._silenced_channels = {}
@@ -115,9 +113,7 @@ class Silence(commands.Cog):
self.bot = bot
self.scheduler = Scheduler(self.__class__.__name__)
- self._init_task = scheduling.create_task(self._async_init(), event_loop=self.bot.loop)
-
- async def _async_init(self) -> None:
+ async def cog_load(self) -> None:
"""Set instance attributes once the guild is available and reschedule unsilences."""
await self.bot.wait_until_guild_available()
@@ -177,7 +173,6 @@ class Silence(commands.Cog):
Passing a voice channel will attempt to move members out of the channel and back to force sync permissions.
If `kick` is True, members will not be added back to the voice channel, and members will be unable to rejoin.
"""
- await self._init_task
channel, duration = self.parse_silence_args(ctx, duration_or_channel, duration)
channel_info = f"#{channel} ({channel.id})"
@@ -281,7 +276,6 @@ class Silence(commands.Cog):
If the channel was silenced indefinitely, notifications for the channel will stop.
"""
- await self._init_task
if channel is None:
channel = ctx.channel
log.debug(f"Unsilencing channel #{channel} from {ctx.author}'s command.")
@@ -467,21 +461,16 @@ class Silence(commands.Cog):
log.info(f"Rescheduling silence for #{channel} ({channel.id}).")
self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel))
- def cog_unload(self) -> None:
- """Cancel the init task and scheduled tasks."""
- # It's important to wait for _init_task (specifically for _reschedule) to be cancelled
- # before cancelling scheduled tasks. Otherwise, it's possible for _reschedule to schedule
- # more tasks after cancel_all has finished, despite _init_task.cancel being called first.
- # This is cause cancel() on its own doesn't block until the task is cancelled.
- self._init_task.cancel()
- self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all())
-
# This cannot be static (must have a __func__ attribute).
async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
return await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx)
+ async def cog_unload(self) -> None:
+ """Cancel all scheduled tasks."""
+ self.scheduler.cancel_all()
+
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Silence cog."""
- bot.add_cog(Silence(bot))
+ await bot.add_cog(Silence(bot))
diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py
index 9583597e0..c43ae8b0c 100644
--- a/bot/exts/moderation/slowmode.py
+++ b/bot/exts/moderation/slowmode.py
@@ -1,7 +1,7 @@
-from typing import Optional
+from typing import Literal, Optional, Union
from dateutil.relativedelta import relativedelta
-from discord import TextChannel
+from discord import TextChannel, Thread
from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
@@ -16,10 +16,12 @@ SLOWMODE_MAX_DELAY = 21600 # seconds
COMMONLY_SLOWMODED_CHANNELS = {
Channels.python_general: "python_general",
- Channels.discord_py: "discordpy",
+ Channels.discord_bots: "discord_bots",
Channels.off_topic_0: "ot0",
}
+MessageHolder = Optional[Union[TextChannel, Thread]]
+
class Slowmode(Cog):
"""Commands for getting and setting slowmode delays of text channels."""
@@ -33,19 +35,23 @@ class Slowmode(Cog):
await ctx.send_help(ctx.command)
@slowmode_group.command(name='get', aliases=['g'])
- async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None:
+ async def get_slowmode(self, ctx: Context, channel: MessageHolder) -> None:
"""Get the slowmode delay for a text channel."""
# Use the channel this command was invoked in if one was not given
if channel is None:
channel = ctx.channel
- delay = relativedelta(seconds=channel.slowmode_delay)
- humanized_delay = time.humanize_delta(delay)
+ humanized_delay = time.humanize_delta(seconds=channel.slowmode_delay)
await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.')
@slowmode_group.command(name='set', aliases=['s'])
- async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None:
+ async def set_slowmode(
+ self,
+ ctx: Context,
+ channel: MessageHolder,
+ delay: Union[DurationDelta, Literal["0s", "0seconds"]],
+ ) -> None:
"""Set the slowmode delay for a text channel."""
# Use the channel this command was invoked in if one was not given
if channel is None:
@@ -53,8 +59,10 @@ class Slowmode(Cog):
# Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta`
# Must do this to get the delta in a particular unit of time
- slowmode_delay = time.relativedelta_to_timedelta(delay).total_seconds()
+ if isinstance(delay, str):
+ delay = relativedelta(seconds=0)
+ slowmode_delay = time.relativedelta_to_timedelta(delay).total_seconds()
humanized_delay = time.humanize_delta(delay)
# Ensure the delay is within discord's limits
@@ -81,7 +89,7 @@ class Slowmode(Cog):
)
@slowmode_group.command(name='reset', aliases=['r'])
- async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None:
+ async def reset_slowmode(self, ctx: Context, channel: MessageHolder) -> None:
"""Reset the slowmode delay for a text channel to 0 seconds."""
await self.set_slowmode(ctx, channel, relativedelta(seconds=0))
@@ -90,6 +98,6 @@ class Slowmode(Cog):
return await has_any_role(*MODERATION_ROLES).predicate(ctx)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Slowmode cog."""
- bot.add_cog(Slowmode(bot))
+ await bot.add_cog(Slowmode(bot))
diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py
index 99bbd8721..a96e96511 100644
--- a/bot/exts/moderation/stream.py
+++ b/bot/exts/moderation/stream.py
@@ -5,6 +5,7 @@ import arrow
import discord
from arrow import Arrow
from async_rediscache import RedisCache
+from botcore.utils import scheduling
from discord.ext import commands
from bot.bot import Bot
@@ -14,9 +15,8 @@ from bot.constants import (
from bot.converters import Expiry
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import scheduling
+from bot.utils import time
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import discord_timestamp, format_infraction_with_duration
log = get_logger(__name__)
@@ -31,19 +31,13 @@ class Stream(commands.Cog):
def __init__(self, bot: Bot):
self.bot = bot
self.scheduler = scheduling.Scheduler(self.__class__.__name__)
- self.reload_task = scheduling.create_task(self._reload_tasks_from_redis(), event_loop=self.bot.loop)
-
- def cog_unload(self) -> None:
- """Cancel all scheduled tasks."""
- self.reload_task.cancel()
- self.reload_task.add_done_callback(lambda _: self.scheduler.cancel_all())
async def _revoke_streaming_permission(self, member: discord.Member) -> None:
"""Remove the streaming permission from the given Member."""
await self.task_cache.delete(member.id)
await member.remove_roles(discord.Object(Roles.video), reason="Streaming access revoked")
- async def _reload_tasks_from_redis(self) -> None:
+ async def cog_load(self) -> None:
"""Reload outstanding tasks from redis on startup, delete the task if the member has since left the server."""
await self.bot.wait_until_guild_available()
items = await self.task_cache.items()
@@ -131,11 +125,15 @@ class Stream(commands.Cog):
await member.add_roles(discord.Object(Roles.video), reason="Temporary streaming access granted")
- await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {discord_timestamp(duration)}.")
+ await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.")
# Convert here for nicer logging
- revoke_time = format_infraction_with_duration(str(duration))
- log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.")
+ humanized_duration = time.humanize_delta(duration, arrow.utcnow(), max_units=2)
+ end_time = duration.strftime("%Y-%m-%d %H:%M:%S")
+ log.debug(
+ f"Successfully gave {member} ({member.id}) permission "
+ f"to stream for {humanized_duration} (until {end_time})."
+ )
@commands.command(aliases=("pstream",))
@commands.has_any_role(*MODERATION_ROLES)
@@ -227,7 +225,11 @@ class Stream(commands.Cog):
else:
await ctx.send("No members with stream permissions found.")
+ async def cog_unload(self) -> None:
+ """Cancel all scheduled tasks."""
+ self.scheduler.cancel_all()
+
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Loads the Stream cog."""
- bot.add_cog(Stream(bot))
+ await bot.add_cog(Stream(bot))
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
index 37338d19c..306c27e06 100644
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -127,6 +127,6 @@ class Verification(Cog):
# endregion
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Verification cog."""
- bot.add_cog(Verification(bot))
+ await bot.add_cog(Verification(bot))
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
index ae55a03a0..90f88d040 100644
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -5,12 +5,12 @@ from datetime import timedelta
import arrow
import discord
from async_rediscache import RedisCache
+from botcore.site_api import ResponseCodeError
from discord import Colour, Member, VoiceState
from discord.ext.commands import Cog, Context, command
-from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf
+from bot.constants import Bot as BotConfig, Channels, MODERATION_ROLES, Roles, VoiceGate as GateConf
from bot.decorators import has_no_roles, in_whitelist
from bot.exts.moderation.modlog import ModLog
from bot.log import get_logger
@@ -30,21 +30,21 @@ FAILED_MESSAGE = (
MESSAGE_FIELD_MAP = {
"joined_at": f"have been on the server for less than {GateConf.minimum_days_member} days",
- "voice_banned": "have an active voice ban infraction",
+ "voice_gate_blocked": "have an active voice infraction",
"total_messages": f"have sent less than {GateConf.minimum_messages} messages",
"activity_blocks": f"have been active for fewer than {GateConf.minimum_activity_blocks} ten-minute blocks",
}
VOICE_PING = (
"Wondering why you can't talk in the voice channels? "
- "Use the `!voiceverify` command in here to verify. "
+ f"Use the `{BotConfig.prefix}voiceverify` command in here to verify. "
"If you don't yet qualify, you'll be told why!"
)
VOICE_PING_DM = (
"Wondering why you can't talk in the voice channels? "
- "Use the `!voiceverify` command in {channel_mention} to verify. "
- "If you don't yet qualify, you'll be told why!"
+ f"Use the `{BotConfig.prefix}voiceverify` command in "
+ "{channel_mention} to verify. If you don't yet qualify, you'll be told why!"
)
@@ -170,12 +170,9 @@ class VoiceGate(Cog):
ctx.author.joined_at > arrow.utcnow() - timedelta(days=GateConf.minimum_days_member)
),
"total_messages": data["total_messages"] < GateConf.minimum_messages,
- "voice_banned": data["voice_banned"],
+ "voice_gate_blocked": data["voice_gate_blocked"],
+ "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks,
}
- if activity_blocks := data.get("activity_blocks"):
- # activity_blocks is not included in the response if the user has a lot of messages.
- # Only check if the user has enough activity blocks if it is included.
- checks["activity_blocks"] = activity_blocks < GateConf.minimum_activity_blocks
failed = any(checks.values())
failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True]
@@ -194,7 +191,6 @@ class VoiceGate(Cog):
await ctx.channel.send(ctx.author.mention, embed=embed)
return
- self.mod_log.ignore(Event.member_update, ctx.author.id)
embed = discord.Embed(
title="Voice gate passed",
description="You have been granted permission to use voice channels in Python Discord.",
@@ -241,10 +237,6 @@ class VoiceGate(Cog):
log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.")
return
- # Ignore deleted voice verification messages
- if ctx.command is not None and ctx.command.name == "voice_verify":
- self.mod_log.ignore(Event.message_delete, message.id)
-
with suppress(discord.NotFound):
await message.delete()
@@ -280,6 +272,6 @@ class VoiceGate(Cog):
error.handled = True
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Loads the VoiceGate cog."""
- bot.add_cog(VoiceGate(bot))
+ await bot.add_cog(VoiceGate(bot))
diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py
index 34d445912..46f9c296e 100644
--- a/bot/exts/moderation/watchchannels/_watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -7,10 +7,11 @@ from dataclasses import dataclass
from typing import Any, Dict, Optional
import discord
+from botcore.site_api import ResponseCodeError
+from botcore.utils import scheduling
from discord import Color, DMChannel, Embed, HTTPException, Message, errors
from discord.ext.commands import Cog, Context
-from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons
from bot.exts.filters.token_remover import TokenRemover
@@ -18,9 +19,8 @@ from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.exts.moderation.modlog import ModLog
from bot.log import CustomLogger, get_logger
from bot.pagination import LinePaginator
-from bot.utils import CogABCMeta, messages, scheduling
+from bot.utils import CogABCMeta, messages, time
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import get_time_delta
log = get_logger(__name__)
@@ -70,8 +70,6 @@ class WatchChannel(metaclass=CogABCMeta):
self.message_history = MessageHistory()
self.disable_header = disable_header
- self._start = scheduling.create_task(self.start_watchchannel(), event_loop=self.bot.loop)
-
@property
def modlog(self) -> ModLog:
"""Provides access to the ModLog cog for alert purposes."""
@@ -94,7 +92,7 @@ class WatchChannel(metaclass=CogABCMeta):
return True
- async def start_watchchannel(self) -> None:
+ async def cog_load(self) -> None:
"""Starts the watch channel by getting the channel, webhook, and user cache ready."""
await self.bot.wait_until_guild_available()
@@ -286,7 +284,7 @@ class WatchChannel(metaclass=CogABCMeta):
actor = actor.display_name if actor else self.watched_users[user_id]['actor']
inserted_at = self.watched_users[user_id]['inserted_at']
- time_delta = get_time_delta(inserted_at)
+ time_delta = time.format_relative(inserted_at)
reason = self.watched_users[user_id]['reason']
@@ -360,7 +358,7 @@ class WatchChannel(metaclass=CogABCMeta):
if member:
line += f" ({member.name}#{member.discriminator})"
inserted_at = user_data['inserted_at']
- line += f", added {get_time_delta(inserted_at)}"
+ line += f", added {time.format_relative(inserted_at)}"
if not member: # Cross off users who left the server.
line = f"~~{line}~~"
list_data["info"][user_id] = line
@@ -375,7 +373,7 @@ class WatchChannel(metaclass=CogABCMeta):
self.message_queue.pop(user_id, None)
self.consumption_queue.pop(user_id, None)
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Takes care of unloading the cog and canceling the consumption task."""
self.log.trace("Unloading the cog")
if self._consume_task and not self._consume_task.done():
diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py
index ab37b1b80..4a746edff 100644
--- a/bot/exts/moderation/watchchannels/bigbrother.py
+++ b/bot/exts/moderation/watchchannels/bigbrother.py
@@ -22,7 +22,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
destination=Channels.big_brother_logs,
webhook_id=Webhooks.big_brother,
api_endpoint='bot/infractions',
- api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'},
+ api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at', 'limit': 10_000},
logger=log
)
@@ -169,6 +169,6 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await ctx.send(message)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the BigBrother cog."""
- bot.add_cog(BigBrother(bot))
+ await bot.add_cog(BigBrother(bot))
diff --git a/bot/exts/recruitment/talentpool/__init__.py b/bot/exts/recruitment/talentpool/__init__.py
index 52d27eb99..aa09a1ee2 100644
--- a/bot/exts/recruitment/talentpool/__init__.py
+++ b/bot/exts/recruitment/talentpool/__init__.py
@@ -1,8 +1,8 @@
from bot.bot import Bot
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the TalentPool cog."""
from bot.exts.recruitment.talentpool._cog import TalentPool
- bot.add_cog(TalentPool(bot))
+ await bot.add_cog(TalentPool(bot))
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index 51f9de238..9819152b0 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -5,19 +5,19 @@ from typing import Optional, Union
import discord
from async_rediscache import RedisCache
+from botcore.site_api import ResponseCodeError
+from botcore.utils import scheduling
from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User
from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role
-from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES
+from bot.constants import Bot as BotConfig, Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES
from bot.converters import MemberOrUser, UnambiguousMemberOrUser
from bot.exts.recruitment.talentpool._review import Reviewer
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import scheduling, time
+from bot.utils import time
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import get_time_delta
AUTOREVIEW_ENABLED_KEY = "autoreview_enabled"
REASON_MAX_CHARS = 1000
@@ -181,7 +181,7 @@ class TalentPool(Cog, name="Talentpool"):
if member:
line += f" ({member.name}#{member.discriminator})"
inserted_at = user_data['inserted_at']
- line += f", added {get_time_delta(inserted_at)}"
+ line += f", added {time.format_relative(inserted_at)}"
if not member: # Cross off users who left the server.
line = f"~~{line}~~"
if user_data['reviewed']:
@@ -237,7 +237,7 @@ class TalentPool(Cog, name="Talentpool"):
if any(role.id in MODERATION_ROLES for role in ctx.author.roles):
await ctx.send(
f":x: Nominations should be run in the <#{Channels.nominations}> channel. "
- "Use `!tp forcenominate` to override this check."
+ f"Use `{BotConfig.prefix}tp forcenominate` to override this check."
)
else:
await ctx.send(f":x: Nominations must be run in the <#{Channels.nominations}> channel.")
@@ -562,7 +562,7 @@ class TalentPool(Cog, name="Talentpool"):
actor = await get_or_fetch_member(guild, actor_id)
reason = site_entry["reason"] or "*None*"
- created = time.format_infraction(site_entry["inserted_at"])
+ created = time.discord_timestamp(site_entry["inserted_at"])
entries.append(
f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}"
)
@@ -571,7 +571,7 @@ class TalentPool(Cog, name="Talentpool"):
active = nomination_object["active"]
- start_date = time.format_infraction(nomination_object["inserted_at"])
+ start_date = time.discord_timestamp(nomination_object["inserted_at"])
if active:
lines = textwrap.dedent(
f"""
@@ -585,7 +585,7 @@ class TalentPool(Cog, name="Talentpool"):
"""
)
else:
- end_date = time.format_infraction(nomination_object["ended_at"])
+ end_date = time.discord_timestamp(nomination_object["ended_at"])
lines = textwrap.dedent(
f"""
===============
@@ -603,7 +603,6 @@ class TalentPool(Cog, name="Talentpool"):
return lines.strip()
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Cancels all review tasks on cog unload."""
- super().cog_unload()
self.reviewer.cancel_all()
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index 0e7194892..f94a15193 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -9,18 +9,18 @@ from datetime import datetime, timedelta
from typing import List, Optional, Union
import arrow
+from botcore.site_api import ResponseCodeError
+from botcore.utils.scheduling import Scheduler
from dateutil.parser import isoparse
-from discord import Embed, Emoji, Member, Message, NoMoreItems, NotFound, PartialMessage, TextChannel
+from discord import Embed, Emoji, Member, Message, NotFound, PartialMessage, TextChannel
from discord.ext.commands import Context
-from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels, Colours, Emojis, Guild, Roles
from bot.log import get_logger
+from bot.utils import time
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import count_unique_users_reaction, pin_no_system_message
-from bot.utils.scheduling import Scheduler
-from bot.utils.time import get_time_delta, time_since
if typing.TYPE_CHECKING:
from bot.exts.recruitment.talentpool._cog import TalentPool
@@ -151,12 +151,11 @@ class Reviewer:
# We consider the first message in the nomination to contain the user ping, username#discrim, and fixed text
messages = [message]
if not NOMINATION_MESSAGE_REGEX.search(message.content):
- with contextlib.suppress(NoMoreItems):
- async for new_message in message.channel.history(before=message.created_at):
- messages.append(new_message)
+ async for new_message in message.channel.history(before=message.created_at):
+ messages.append(new_message)
- if NOMINATION_MESSAGE_REGEX.search(new_message.content):
- break
+ if NOMINATION_MESSAGE_REGEX.search(new_message.content):
+ break
log.debug(f"Found {len(messages)} messages: {', '.join(str(m.id) for m in messages)}")
@@ -273,7 +272,7 @@ class Reviewer:
last_channel = user_activity["top_channel_activity"][-1]
channels += f", and {last_channel[1]} in {last_channel[0]}"
- joined_at_formatted = time_since(member.joined_at)
+ joined_at_formatted = time.format_relative(member.joined_at)
review = (
f"{member.name} joined the server **{joined_at_formatted}**"
f" and has **{messages} messages**{channels}."
@@ -321,7 +320,7 @@ class Reviewer:
infractions += ", with the last infraction issued "
# Infractions were ordered by time since insertion descending.
- infractions += get_time_delta(infraction_list[0]['inserted_at'])
+ infractions += time.format_relative(infraction_list[0]['inserted_at'])
return f"They have {infractions}."
@@ -365,7 +364,7 @@ class Reviewer:
nomination_times = f"{num_entries} times" if num_entries > 1 else "once"
rejection_times = f"{len(history)} times" if len(history) > 1 else "once"
- end_time = time_since(isoparse(history[0]['ended_at']))
+ end_time = time.format_relative(history[0]['ended_at'])
review = (
f"They were nominated **{nomination_times}** before"
diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py
index 788692777..a312e0584 100644
--- a/bot/exts/utils/bot.py
+++ b/bot/exts/utils/bot.py
@@ -1,11 +1,10 @@
-from contextlib import suppress
from typing import Optional
-from discord import Embed, Forbidden, TextChannel, Thread
+from discord import Embed, TextChannel
from discord.ext.commands import Cog, Context, command, group, has_any_role
from bot.bot import Bot
-from bot.constants import Guild, MODERATION_ROLES, URLs
+from bot.constants import Bot as BotConfig, Guild, MODERATION_ROLES, URLs
from bot.log import get_logger
log = get_logger(__name__)
@@ -17,20 +16,6 @@ class BotCog(Cog, name="Bot"):
def __init__(self, bot: Bot):
self.bot = bot
- @Cog.listener()
- async def on_thread_join(self, thread: Thread) -> None:
- """
- Try to join newly created threads.
-
- Despite the event name being misleading, this is dispatched when new threads are created.
- """
- if thread.me:
- # We have already joined this thread
- return
-
- with suppress(Forbidden):
- await thread.join()
-
@group(invoke_without_command=True, name="bot", hidden=True)
async def botinfo_group(self, ctx: Context) -> None:
"""Bot informational commands."""
@@ -40,7 +25,10 @@ class BotCog(Cog, name="Bot"):
async def about_command(self, ctx: Context) -> None:
"""Get information about the bot."""
embed = Embed(
- description="A utility bot designed just for the Python server! Try `!help` for more info.",
+ description=(
+ "A utility bot designed just for the Python server! "
+ f"Try `{BotConfig.prefix}help` for more info."
+ ),
url="https://github.com/python-discord/bot"
)
@@ -76,6 +64,6 @@ class BotCog(Cog, name="Bot"):
await channel.send(embed=embed)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Bot cog."""
- bot.add_cog(BotCog(bot))
+ await bot.add_cog(BotCog(bot))
diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py
index fda1e49e2..0f5fc0de4 100644
--- a/bot/exts/utils/extensions.py
+++ b/bot/exts/utils/extensions.py
@@ -12,7 +12,6 @@ from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs
from bot.converters import Extension
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils.extensions import EXTENSIONS
log = get_logger(__name__)
@@ -53,9 +52,9 @@ class Extensions(commands.Cog):
return
if "*" in extensions or "**" in extensions:
- extensions = set(EXTENSIONS) - set(self.bot.extensions.keys())
+ extensions = set(self.bot.all_extensions) - set(self.bot.extensions.keys())
- msg = self.batch_manage(Action.LOAD, *extensions)
+ msg = await self.batch_manage(Action.LOAD, *extensions)
await ctx.send(msg)
@extensions_group.command(name="unload", aliases=("ul",))
@@ -77,7 +76,7 @@ class Extensions(commands.Cog):
if "*" in extensions or "**" in extensions:
extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST
- msg = self.batch_manage(Action.UNLOAD, *extensions)
+ msg = await self.batch_manage(Action.UNLOAD, *extensions)
await ctx.send(msg)
@@ -96,12 +95,12 @@ class Extensions(commands.Cog):
return
if "**" in extensions:
- extensions = EXTENSIONS
+ extensions = self.bot.all_extensions
elif "*" in extensions:
extensions = set(self.bot.extensions.keys()) | set(extensions)
extensions.remove("*")
- msg = self.batch_manage(Action.RELOAD, *extensions)
+ msg = await self.batch_manage(Action.RELOAD, *extensions)
await ctx.send(msg)
@@ -136,7 +135,7 @@ class Extensions(commands.Cog):
"""Return a mapping of extension names and statuses to their categories."""
categories = {}
- for ext in EXTENSIONS:
+ for ext in self.bot.all_extensions:
if ext in self.bot.extensions:
status = Emojis.status_online
else:
@@ -152,21 +151,21 @@ class Extensions(commands.Cog):
return categories
- def batch_manage(self, action: Action, *extensions: str) -> str:
+ async def batch_manage(self, action: Action, *extensions: str) -> str:
"""
Apply an action to multiple extensions and return a message with the results.
If only one extension is given, it is deferred to `manage()`.
"""
if len(extensions) == 1:
- msg, _ = self.manage(action, extensions[0])
+ msg, _ = await self.manage(action, extensions[0])
return msg
verb = action.name.lower()
failures = {}
for extension in extensions:
- _, error = self.manage(action, extension)
+ _, error = await self.manage(action, extension)
if error:
failures[extension] = error
@@ -181,17 +180,17 @@ class Extensions(commands.Cog):
return msg
- def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]:
+ async def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]:
"""Apply an action to an extension and return the status message and any error message."""
verb = action.name.lower()
error_msg = None
try:
- action.value(self.bot, ext)
+ await action.value(self.bot, ext)
except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded):
if action is Action.RELOAD:
# When reloading, just load the extension if it was not loaded.
- return self.manage(Action.LOAD, ext)
+ return await self.manage(Action.LOAD, ext)
msg = f":x: Extension `{ext}` is already {verb}ed."
log.debug(msg[4:])
@@ -222,6 +221,6 @@ class Extensions(commands.Cog):
error.handled = True
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Extensions cog."""
- bot.add_cog(Extensions(bot))
+ await bot.add_cog(Extensions(bot))
diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py
index e7113c09c..3125cee75 100644
--- a/bot/exts/utils/internal.py
+++ b/bot/exts/utils/internal.py
@@ -16,6 +16,7 @@ from bot.bot import Bot
from bot.constants import DEBUG_MODE, Roles
from bot.log import get_logger
from bot.utils import find_nth_occurrence, send_to_paste_service
+from bot.utils.services import PasteTooLongError, PasteUploadError
log = get_logger(__name__)
@@ -194,11 +195,14 @@ async def func(): # (None,) -> Any
truncate_index = newline_truncate_index
if len(out) > truncate_index:
- paste_link = await send_to_paste_service(out, extension="py")
- if paste_link is not None:
- paste_text = f"full contents at {paste_link}"
- else:
+ try:
+ paste_link = await send_to_paste_service(out, extension="py")
+ except PasteTooLongError:
+ paste_text = "too long to upload to paste service."
+ except PasteUploadError:
paste_text = "failed to upload contents to paste service."
+ else:
+ paste_text = f"full contents at {paste_link}"
await ctx.send(
f"```py\n{out[:truncate_index]}\n```"
@@ -252,6 +256,6 @@ async def func(): # (None,) -> Any
await ctx.send(embed=stats_embed)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Internal cog."""
- bot.add_cog(Internal(bot))
+ await bot.add_cog(Internal(bot))
diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py
index 9fb5b7b8f..67a960365 100644
--- a/bot/exts/utils/ping.py
+++ b/bot/exts/utils/ping.py
@@ -60,6 +60,6 @@ class Latency(commands.Cog):
await ctx.send(embed=embed)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Latency cog."""
- bot.add_cog(Latency(bot))
+ await bot.add_cog(Latency(bot))
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 90677b2dd..45cddd7a2 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -5,6 +5,8 @@ from datetime import datetime, timezone
from operator import itemgetter
import discord
+from botcore.utils import scheduling
+from botcore.utils.scheduling import Scheduler
from dateutil.parser import isoparse
from discord.ext.commands import Cog, Context, Greedy, group
@@ -13,13 +15,11 @@ from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Role
from bot.converters import Duration, UnambiguousUser
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import scheduling
+from bot.utils import time
from bot.utils.checks import has_any_role_check, has_no_roles_check
from bot.utils.lock import lock_arg
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import send_denial
-from bot.utils.scheduling import Scheduler
-from bot.utils.time import TimestampFormats, discord_timestamp
log = get_logger(__name__)
@@ -38,13 +38,11 @@ class Reminders(Cog):
self.bot = bot
self.scheduler = Scheduler(self.__class__.__name__)
- scheduling.create_task(self.reschedule_reminders(), event_loop=self.bot.loop)
-
- def cog_unload(self) -> None:
+ async def cog_unload(self) -> None:
"""Cancel scheduled tasks."""
self.scheduler.cancel_all()
- async def reschedule_reminders(self) -> None:
+ async def cog_load(self) -> None:
"""Get all current reminders from the API and reschedule them."""
await self.bot.wait_until_guild_available()
response = await self.bot.api_client.get(
@@ -67,20 +65,19 @@ class Reminders(Cog):
else:
self.schedule_reminder(reminder)
- def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]:
- """Ensure reminder author and channel can be fetched otherwise delete the reminder."""
- user = self.bot.get_user(reminder['author'])
+ def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.TextChannel]:
+ """Ensure reminder channel can be fetched otherwise delete the reminder."""
channel = self.bot.get_channel(reminder['channel_id'])
is_valid = True
- if not user or not channel:
+ if not channel:
is_valid = False
log.info(
f"Reminder {reminder['id']} invalid: "
- f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}."
+ f"Channel {reminder['channel_id']}={channel}."
)
scheduling.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}"))
- return is_valid, user, channel
+ return is_valid, channel
@staticmethod
async def _send_confirmation(
@@ -169,9 +166,9 @@ class Reminders(Cog):
self.schedule_reminder(reminder)
@lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
- async def send_reminder(self, reminder: dict, expected_time: datetime = None) -> None:
+ async def send_reminder(self, reminder: dict, expected_time: t.Optional[time.Timestamp] = None) -> None:
"""Send the reminder."""
- is_valid, user, channel = self.ensure_valid_reminder(reminder)
+ is_valid, channel = self.ensure_valid_reminder(reminder)
if not is_valid:
# No need to cancel the task too; it'll simply be done once this coroutine returns.
return
@@ -207,7 +204,7 @@ class Reminders(Cog):
f"There was an error when trying to reply to a reminder invocation message, {e}, "
"fall back to using jump_url"
)
- await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed)
+ await channel.send(content=f"<@{reminder['author']}> {additional_mentions}", embed=embed)
log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).")
await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")
@@ -310,7 +307,8 @@ class Reminders(Cog):
}
)
- mention_string = f"Your reminder will arrive on {discord_timestamp(expiration, TimestampFormats.DAY_TIME)}"
+ formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME)
+ mention_string = f"Your reminder will arrive on {formatted_time}"
if mentions:
mention_string += f" and will mention {len(mentions)} other(s)"
@@ -347,8 +345,7 @@ class Reminders(Cog):
for content, remind_at, id_, mentions in reminders:
# Parse and humanize the time, make it pretty :D
- remind_datetime = isoparse(remind_at)
- time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE)
+ expiry = time.format_relative(remind_at)
mentions = ", ".join([
# Both Role and User objects have the `name` attribute
@@ -357,7 +354,7 @@ class Reminders(Cog):
mention_string = f"\n**Mentions:** {mentions}" if mentions else ""
text = textwrap.dedent(f"""
- **Reminder #{id_}:** *expires {time}* (ID: {id_}){mention_string}
+ **Reminder #{id_}:** *expires {expiry}* (ID: {id_}){mention_string}
{content}
""").strip()
@@ -488,6 +485,6 @@ class Reminders(Cog):
return True
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Reminders cog."""
- bot.add_cog(Reminders(bot))
+ await bot.add_cog(Reminders(bot))
diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py
index fbfc58d0b..98dfd2efa 100644
--- a/bot/exts/utils/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -2,120 +2,181 @@ import asyncio
import contextlib
import datetime
import re
-import textwrap
from functools import partial
from signal import Signals
+from textwrap import dedent
from typing import Optional, Tuple
-from discord import HTTPException, Message, NotFound, Reaction, User
-from discord.ext.commands import Cog, Context, command, guild_only
+from botcore.utils import scheduling
+from botcore.utils.regex import FORMATTED_CODE_REGEX, RAW_CODE_REGEX
+from discord import AllowedMentions, HTTPException, Message, NotFound, Reaction, User
+from discord.ext.commands import Cog, Command, Context, Converter, command, guild_only
from bot.bot import Bot
from bot.constants import Categories, Channels, Roles, URLs
from bot.decorators import redirect_output
from bot.log import get_logger
-from bot.utils import scheduling, send_to_paste_service
+from bot.utils import send_to_paste_service
from bot.utils.messages import wait_for_deletion
+from bot.utils.services import PasteTooLongError, PasteUploadError
log = get_logger(__name__)
ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}")
-FORMATTED_CODE_REGEX = re.compile(
- r"(?P<delim>(?P<block>```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block
- r"(?(block)(?:(?P<lang>[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline)
- r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
- r"(?P<code>.*?)" # extract all code inside the markup
- r"\s*" # any more whitespace before the end of the code markup
- r"(?P=delim)", # match the exact same delimiter from the start again
- re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive
-)
-RAW_CODE_REGEX = re.compile(
- r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
- r"(?P<code>.*?)" # extract all the rest as code
- r"\s*$", # any trailing whitespace until the end of the string
- re.DOTALL # "." also matches newlines
-)
-
-MAX_PASTE_LEN = 10000
-
-# `!eval` command whitelists and blacklists.
-NO_EVAL_CHANNELS = (Channels.python_general,)
-NO_EVAL_CATEGORIES = ()
-EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners)
-SIGKILL = 9
+# The timeit command should only output the very last line, so all other output should be suppressed.
+# This will be used as the setup code along with any setup code provided.
+TIMEIT_SETUP_WRAPPER = """
+import atexit
+import sys
+from collections import deque
-REEVAL_EMOJI = '\U0001f501' # :repeat:
-REEVAL_TIMEOUT = 30
+if not hasattr(sys, "_setup_finished"):
+ class Writer(deque):
+ '''A single-item deque wrapper for sys.stdout that will return the last line when read() is called.'''
+ def __init__(self):
+ super().__init__(maxlen=1)
-class Snekbox(Cog):
- """Safe evaluation of Python code using Snekbox."""
+ def write(self, string):
+ '''Append the line to the queue if it is not empty.'''
+ if string.strip():
+ self.append(string)
- def __init__(self, bot: Bot):
- self.bot = bot
- self.jobs = {}
+ def read(self):
+ '''This method will be called when print() is called.
- async def post_eval(self, code: str) -> dict:
- """Send a POST request to the Snekbox API to evaluate code and return the results."""
- url = URLs.snekbox_eval_api
- data = {"input": code}
- async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp:
- return await resp.json()
+ The queue is emptied as we don't need the output later.
+ '''
+ return self.pop()
- async def upload_output(self, output: str) -> Optional[str]:
- """Upload the eval output to a paste service and return a URL to it if successful."""
- log.trace("Uploading full output to paste service...")
+ def flush(self):
+ '''This method will be called eventually, but we don't need to do anything here.'''
+ pass
- if len(output) > MAX_PASTE_LEN:
- log.info("Full output is too long to upload")
- return "too long to upload"
- return await send_to_paste_service(output, extension="txt")
+ sys.stdout = Writer()
- @staticmethod
- def prepare_input(code: str) -> str:
+ def print_last_line():
+ if sys.stdout: # If the deque is empty (i.e. an error happened), calling read() will raise an error
+ # Use sys.__stdout__ here because sys.stdout is set to a Writer() instance
+ print(sys.stdout.read(), file=sys.__stdout__)
+
+ atexit.register(print_last_line) # When exiting, print the last line (hopefully it will be the timeit output)
+ sys._setup_finished = None
+{setup}
+"""
+
+MAX_PASTE_LENGTH = 10_000
+
+# The Snekbox commands' whitelists and blacklists.
+NO_SNEKBOX_CHANNELS = (Channels.python_general,)
+NO_SNEKBOX_CATEGORIES = ()
+SNEKBOX_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners)
+
+SIGKILL = 9
+
+REDO_EMOJI = '\U0001f501' # :repeat:
+REDO_TIMEOUT = 30
+
+
+class CodeblockConverter(Converter):
+ """Attempts to extract code from a codeblock, if provided."""
+
+ @classmethod
+ async def convert(cls, ctx: Context, code: str) -> list[str]:
"""
Extract code from the Markdown, format it, and insert it into the code template.
If there is any code block, ignore text outside the code block.
Use the first code block, but prefer a fenced code block.
If there are several fenced code blocks, concatenate only the fenced code blocks.
+
+ Return a list of code blocks if any, otherwise return a list with a single string of code.
"""
if match := list(FORMATTED_CODE_REGEX.finditer(code)):
blocks = [block for block in match if block.group("block")]
if len(blocks) > 1:
- code = '\n'.join(block.group("code") for block in blocks)
+ codeblocks = [block.group("code") for block in blocks]
info = "several code blocks"
else:
match = match[0] if len(blocks) == 0 else blocks[0]
code, block, lang, delim = match.group("code", "block", "lang", "delim")
+ codeblocks = [dedent(code)]
if block:
info = (f"'{lang}' highlighted" if lang else "plain") + " code block"
else:
info = f"{delim}-enclosed inline code"
else:
- code = RAW_CODE_REGEX.fullmatch(code).group("code")
+ codeblocks = [dedent(RAW_CODE_REGEX.fullmatch(code).group("code"))]
info = "unformatted or badly formatted code"
- code = textwrap.dedent(code)
+ code = "\n".join(codeblocks)
log.trace(f"Extracted {info} for evaluation:\n{code}")
- return code
+ return codeblocks
+
+
+class Snekbox(Cog):
+ """Safe evaluation of Python code using Snekbox."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.jobs = {}
+
+ async def post_job(self, code: str, *, args: Optional[list[str]] = None) -> dict:
+ """Send a POST request to the Snekbox API to evaluate code and return the results."""
+ url = URLs.snekbox_eval_api
+ data = {"input": code}
+
+ if args is not None:
+ data["args"] = args
+
+ async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp:
+ return await resp.json()
+
+ async def upload_output(self, output: str) -> Optional[str]:
+ """Upload the job's output to a paste service and return a URL to it if successful."""
+ log.trace("Uploading full output to paste service...")
+
+ try:
+ return await send_to_paste_service(output, extension="txt", max_length=MAX_PASTE_LENGTH)
+ except PasteTooLongError:
+ return "too long to upload"
+ except PasteUploadError:
+ return "unable to upload"
+
+ @staticmethod
+ def prepare_timeit_input(codeblocks: list[str]) -> tuple[str, list[str]]:
+ """
+ Join the codeblocks into a single string, then return the code and the arguments in a tuple.
+
+ If there are multiple codeblocks, insert the first one into the wrapped setup code.
+ """
+ args = ["-m", "timeit"]
+ setup = ""
+ if len(codeblocks) > 1:
+ setup = codeblocks.pop(0)
+
+ code = "\n".join(codeblocks)
+
+ args.extend(["-s", TIMEIT_SETUP_WRAPPER.format(setup=setup)])
+
+ return code, args
@staticmethod
- def get_results_message(results: dict) -> Tuple[str, str]:
+ def get_results_message(results: dict, job_name: str) -> Tuple[str, str]:
"""Return a user-friendly message and error corresponding to the process's return code."""
stdout, returncode = results["stdout"], results["returncode"]
- msg = f"Your eval job has completed with return code {returncode}"
+ msg = f"Your {job_name} job has completed with return code {returncode}"
error = ""
if returncode is None:
- msg = "Your eval job has failed"
+ msg = f"Your {job_name} job has failed"
error = stdout.strip()
elif returncode == 128 + SIGKILL:
- msg = "Your eval job timed out or ran out of memory"
+ msg = f"Your {job_name} job timed out or ran out of memory"
elif returncode == 255:
- msg = "Your eval job has failed"
+ msg = f"Your {job_name} job has failed"
error = "A fatal NsJail error occurred"
else:
# Try to append signal's name if one exists
@@ -144,8 +205,6 @@ class Snekbox(Cog):
Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters
and upload the full output to a paste service.
"""
- log.trace("Formatting output...")
-
output = output.rstrip("\n")
original_output = output # To be uploaded to a pasting service if needed
paste_link = None
@@ -185,19 +244,27 @@ class Snekbox(Cog):
return output, paste_link
- async def send_eval(self, ctx: Context, code: str) -> Message:
+ async def send_job(
+ self,
+ ctx: Context,
+ code: str,
+ *,
+ args: Optional[list[str]] = None,
+ job_name: str
+ ) -> Message:
"""
Evaluate code, format it, and send the output to the corresponding channel.
Return the bot response.
"""
async with ctx.typing():
- results = await self.post_eval(code)
- msg, error = self.get_results_message(results)
+ results = await self.post_job(code, args=args)
+ msg, error = self.get_results_message(results, job_name)
if error:
output, paste_link = error, None
else:
+ log.trace("Formatting output...")
output, paste_link = await self.format_output(results["stdout"])
icon = self.get_status_emoji(results)
@@ -205,7 +272,7 @@ class Snekbox(Cog):
if paste_link:
msg = f"{msg}\nFull output: {paste_link}"
- # Collect stats of eval fails + successes
+ # Collect stats of job fails + successes
if icon == ":x:":
self.bot.stats.incr("snekbox.python.fail")
else:
@@ -214,90 +281,93 @@ class Snekbox(Cog):
filter_cog = self.bot.get_cog("Filtering")
filter_triggered = False
if filter_cog:
- filter_triggered = await filter_cog.filter_eval(msg, ctx.message)
+ filter_triggered = await filter_cog.filter_snekbox_output(msg, ctx.message)
if filter_triggered:
response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.")
else:
- response = await ctx.send(msg)
+ allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author])
+ response = await ctx.send(msg, allowed_mentions=allowed_mentions)
scheduling.create_task(wait_for_deletion(response, (ctx.author.id,)), event_loop=self.bot.loop)
- log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")
+ log.info(f"{ctx.author}'s {job_name} job had a return code of {results['returncode']}")
return response
- async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]:
+ async def continue_job(
+ self, ctx: Context, response: Message, command: Command
+ ) -> tuple[Optional[str], Optional[list[str]]]:
"""
- Check if the eval session should continue.
+ Check if the job's session should continue.
- Return the new code to evaluate or None if the eval session should be terminated.
+ If the code is to be re-evaluated, return the new code, and the args if the command is the timeit command.
+ Otherwise return (None, None) if the job's session should be terminated.
"""
- _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx)
- _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx)
+ _predicate_message_edit = partial(predicate_message_edit, ctx)
+ _predicate_emoji_reaction = partial(predicate_emoji_reaction, ctx)
with contextlib.suppress(NotFound):
try:
_, new_message = await self.bot.wait_for(
'message_edit',
- check=_predicate_eval_message_edit,
- timeout=REEVAL_TIMEOUT
+ check=_predicate_message_edit,
+ timeout=REDO_TIMEOUT
)
- await ctx.message.add_reaction(REEVAL_EMOJI)
+ await ctx.message.add_reaction(REDO_EMOJI)
await self.bot.wait_for(
'reaction_add',
check=_predicate_emoji_reaction,
timeout=10
)
- code = await self.get_code(new_message)
- await ctx.message.clear_reaction(REEVAL_EMOJI)
+ code = await self.get_code(new_message, ctx.command)
+ await ctx.message.clear_reaction(REDO_EMOJI)
with contextlib.suppress(HTTPException):
await response.delete()
+ if code is None:
+ return None, None
+
except asyncio.TimeoutError:
- await ctx.message.clear_reaction(REEVAL_EMOJI)
- return None
+ await ctx.message.clear_reaction(REDO_EMOJI)
+ return None, None
+
+ codeblocks = await CodeblockConverter.convert(ctx, code)
+
+ if command is self.timeit_command:
+ return self.prepare_timeit_input(codeblocks)
+ else:
+ return "\n".join(codeblocks), None
- return code
+ return None, None
- async def get_code(self, message: Message) -> Optional[str]:
+ async def get_code(self, message: Message, command: Command) -> Optional[str]:
"""
Return the code from `message` to be evaluated.
- If the message is an invocation of the eval command, return the first argument or None if it
+ If the message is an invocation of the command, return the first argument or None if it
doesn't exist. Otherwise, return the full content of the message.
"""
log.trace(f"Getting context for message {message.id}.")
new_ctx = await self.bot.get_context(message)
- if new_ctx.command is self.eval_command:
- log.trace(f"Message {message.id} invokes eval command.")
+ if new_ctx.command is command:
+ log.trace(f"Message {message.id} invokes {command} command.")
split = message.content.split(maxsplit=1)
code = split[1] if len(split) > 1 else None
else:
- log.trace(f"Message {message.id} does not invoke eval command.")
+ log.trace(f"Message {message.id} does not invoke {command} command.")
code = message.content
return code
- @command(name="eval", aliases=("e",))
- @guild_only()
- @redirect_output(
- destination_channel=Channels.bot_commands,
- bypass_roles=EVAL_ROLES,
- categories=NO_EVAL_CATEGORIES,
- channels=NO_EVAL_CHANNELS,
- ping_user=False
- )
- async def eval_command(self, ctx: Context, *, code: str = None) -> None:
- """
- Run Python code and get the results.
-
- This command supports multiple lines of code, including code wrapped inside a formatted code
- block. Code can be re-evaluated by editing the original message within 10 seconds and
- clicking the reaction that subsequently appears.
-
- We've done our best to make this sandboxed, but do let us know if you manage to find an
- issue with it!
- """
+ async def run_job(
+ self,
+ job_name: str,
+ ctx: Context,
+ code: str,
+ *,
+ args: Optional[list[str]] = None,
+ ) -> None:
+ """Handles checks, stats and re-evaluation of a snekbox job."""
if ctx.author.id in self.jobs:
await ctx.send(
f"{ctx.author.mention} You've already got a job running - "
@@ -305,10 +375,6 @@ class Snekbox(Cog):
)
return
- if not code: # None or empty string
- await ctx.send_help(ctx.command)
- return
-
if Roles.helpers in (role.id for role in ctx.author.roles):
self.bot.stats.incr("snekbox_usages.roles.helpers")
else:
@@ -325,28 +391,76 @@ class Snekbox(Cog):
while True:
self.jobs[ctx.author.id] = datetime.datetime.now()
- code = self.prepare_input(code)
try:
- response = await self.send_eval(ctx, code)
+ response = await self.send_job(ctx, code, args=args, job_name=job_name)
finally:
del self.jobs[ctx.author.id]
- code = await self.continue_eval(ctx, response)
+ code, args = await self.continue_job(ctx, response, ctx.command)
if not code:
break
log.info(f"Re-evaluating code from message {ctx.message.id}:\n{code}")
+ @command(name="eval", aliases=("e",))
+ @guild_only()
+ @redirect_output(
+ destination_channel=Channels.bot_commands,
+ bypass_roles=SNEKBOX_ROLES,
+ categories=NO_SNEKBOX_CATEGORIES,
+ channels=NO_SNEKBOX_CHANNELS,
+ ping_user=False
+ )
+ async def eval_command(self, ctx: Context, *, code: CodeblockConverter) -> None:
+ """
+ Run Python code and get the results.
+
+ This command supports multiple lines of code, including code wrapped inside a formatted code
+ block. Code can be re-evaluated by editing the original message within 10 seconds and
+ clicking the reaction that subsequently appears.
+
+ We've done our best to make this sandboxed, but do let us know if you manage to find an
+ issue with it!
+ """
+ await self.run_job("eval", ctx, "\n".join(code))
+
+ @command(name="timeit", aliases=("ti",))
+ @guild_only()
+ @redirect_output(
+ destination_channel=Channels.bot_commands,
+ bypass_roles=SNEKBOX_ROLES,
+ categories=NO_SNEKBOX_CATEGORIES,
+ channels=NO_SNEKBOX_CHANNELS,
+ ping_user=False
+ )
+ async def timeit_command(self, ctx: Context, *, code: CodeblockConverter) -> None:
+ """
+ Profile Python Code to find execution time.
+
+ This command supports multiple lines of code, including code wrapped inside a formatted code
+ block. Code can be re-evaluated by editing the original message within 10 seconds and
+ clicking the reaction that subsequently appears.
+
+ If multiple formatted codeblocks are provided, the first one will be the setup code, which will
+ not be timed. The remaining codeblocks will be joined together and timed.
+
+ We've done our best to make this sandboxed, but do let us know if you manage to find an
+ issue with it!
+ """
+ code, args = self.prepare_timeit_input(code)
+
+ await self.run_job("timeit", ctx, code=code, args=args)
+
-def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool:
+def predicate_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool:
"""Return True if the edited message is the context message and the content was indeed modified."""
return new_msg.id == ctx.message.id and old_msg.content != new_msg.content
-def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool:
- """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message."""
- return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI
+def predicate_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool:
+ """Return True if the reaction REDO_EMOJI was added by the context message author on this message."""
+ return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REDO_EMOJI
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Snekbox cog."""
- bot.add_cog(Snekbox(bot))
+ await bot.add_cog(Snekbox(bot))
diff --git a/bot/exts/utils/thread_bumper.py b/bot/exts/utils/thread_bumper.py
new file mode 100644
index 000000000..a2f208484
--- /dev/null
+++ b/bot/exts/utils/thread_bumper.py
@@ -0,0 +1,158 @@
+import typing as t
+
+import discord
+from botcore.site_api import ResponseCodeError
+from discord.ext import commands
+
+from bot import constants
+from bot.bot import Bot
+from bot.log import get_logger
+from bot.pagination import LinePaginator
+from bot.utils import channel
+
+log = get_logger(__name__)
+THREAD_BUMP_ENDPOINT = "bot/bumped-threads"
+
+
+class ThreadBumper(commands.Cog):
+ """Cog that allow users to add the current thread to a list that get reopened on archive."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ async def thread_exists_in_site(self, thread_id: int) -> bool:
+ """Return whether the given thread_id exists in the site api's bump list."""
+ # If the thread exists, site returns a 204 with no content.
+ # Due to this, `api_client.request()` cannot be used, as it always attempts to decode the response as json.
+ # Instead, call the site manually using the api_client's session, to use the auth token logic in the wrapper.
+
+ async with self.bot.api_client.session.get(
+ f"{self.bot.api_client._url_for(THREAD_BUMP_ENDPOINT)}/{thread_id}"
+ ) as response:
+ if response.status == 204:
+ return True
+ elif response.status == 404:
+ return False
+ else:
+ # A status other than 204/404 is undefined behaviour from site. Raise error for investigation.
+ raise ResponseCodeError(response, response.text())
+
+ async def unarchive_threads_not_manually_archived(self, threads: list[discord.Thread]) -> None:
+ """
+ Iterate through and unarchive any threads that weren't manually archived recently.
+
+ This is done by extracting the manually archived threads from the audit log.
+
+ Only the last 200 thread_update logs are checked,
+ as this is assumed to be more than enough to cover bot downtime.
+ """
+ guild = self.bot.get_guild(constants.Guild.id)
+
+ recent_manually_archived_thread_ids = []
+ async for thread_update in guild.audit_logs(limit=200, action=discord.AuditLogAction.thread_update):
+ if getattr(thread_update.after, "archived", False):
+ recent_manually_archived_thread_ids.append(thread_update.target.id)
+
+ for thread in threads:
+ if thread.id in recent_manually_archived_thread_ids:
+ log.info(
+ "#%s (%d) was manually archived. Leaving archived, and removing from bumped threads.",
+ thread.name,
+ thread.id
+ )
+ await self.bot.api_client.delete(f"{THREAD_BUMP_ENDPOINT}/{thread.id}")
+ else:
+ await thread.edit(archived=False)
+
+ async def cog_load(self) -> None:
+ """Ensure bumped threads are active, since threads could have been archived while the bot was down."""
+ await self.bot.wait_until_guild_available()
+
+ threads_to_maybe_bump = []
+ for thread_id in await self.bot.api_client.get(THREAD_BUMP_ENDPOINT):
+ try:
+ thread = await channel.get_or_fetch_channel(thread_id)
+ except discord.NotFound:
+ log.info("Thread %d has been deleted, removing from bumped threads.", thread_id)
+ await self.bot.api_client.delete(f"{THREAD_BUMP_ENDPOINT}/{thread_id}")
+ continue
+
+ if not isinstance(thread, discord.Thread):
+ await self.bot.api_client.delete(f"{THREAD_BUMP_ENDPOINT}/{thread_id}")
+ continue
+
+ if thread.archived:
+ threads_to_maybe_bump.append(thread)
+
+ if threads_to_maybe_bump:
+ await self.unarchive_threads_not_manually_archived(threads_to_maybe_bump)
+
+ @commands.group(name="bump")
+ async def thread_bump_group(self, ctx: commands.Context) -> None:
+ """A group of commands to manage the bumping of threads."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @thread_bump_group.command(name="add", aliases=("a",))
+ async def add_thread_to_bump_list(self, ctx: commands.Context, thread: t.Optional[discord.Thread]) -> None:
+ """Add a thread to the bump list."""
+ if not thread:
+ if isinstance(ctx.channel, discord.Thread):
+ thread = ctx.channel
+ else:
+ raise commands.BadArgument("You must provide a thread, or run this command within a thread.")
+
+ if await self.thread_exists_in_site(thread.id):
+ raise commands.BadArgument("This thread is already in the bump list.")
+
+ await self.bot.api_client.post(THREAD_BUMP_ENDPOINT, data={"thread_id": thread.id})
+ await ctx.send(f":ok_hand:{thread.mention} has been added to the bump list.")
+
+ @thread_bump_group.command(name="remove", aliases=("r", "rem", "d", "del", "delete"))
+ async def remove_thread_from_bump_list(self, ctx: commands.Context, thread: t.Optional[discord.Thread]) -> None:
+ """Remove a thread from the bump list."""
+ if not thread:
+ if isinstance(ctx.channel, discord.Thread):
+ thread = ctx.channel
+ else:
+ raise commands.BadArgument("You must provide a thread, or run this command within a thread.")
+
+ if not await self.thread_exists_in_site(thread.id):
+ raise commands.BadArgument("This thread is not in the bump list.")
+
+ await self.bot.api_client.delete(f"{THREAD_BUMP_ENDPOINT}/{thread.id}")
+ await ctx.send(f":ok_hand: {thread.mention} has been removed from the bump list.")
+
+ @thread_bump_group.command(name="list", aliases=("get",))
+ async def list_all_threads_in_bump_list(self, ctx: commands.Context) -> None:
+ """List all the threads in the bump list."""
+ lines = [f"<#{thread_id}>" for thread_id in await self.bot.api_client.get(THREAD_BUMP_ENDPOINT)]
+ embed = discord.Embed(
+ title="Threads in the bump list",
+ colour=constants.Colours.blue
+ )
+ await LinePaginator.paginate(lines, ctx, embed, max_lines=10)
+
+ @commands.Cog.listener()
+ async def on_thread_update(self, _: discord.Thread, after: discord.Thread) -> None:
+ """
+ Listen for thread updates and check if the thread has been archived.
+
+ If the thread has been archived, and is in the bump list, un-archive it.
+ """
+ if not after.archived:
+ return
+
+ if await self.thread_exists_in_site(after.id):
+ await self.unarchive_threads_not_manually_archived([after])
+
+ async def cog_check(self, ctx: commands.Context) -> bool:
+ """Only allow staff & partner roles to invoke the commands in this cog."""
+ return await commands.has_any_role(
+ *constants.STAFF_PARTNERS_COMMUNITY_ROLES
+ ).predicate(ctx)
+
+
+async def setup(bot: Bot) -> None:
+ """Load the ThreadBumper cog."""
+ await bot.add_cog(ThreadBumper(bot))
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index 821cebd8c..975e0f56d 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -13,8 +13,7 @@ from bot.converters import Snowflake
from bot.decorators import in_whitelist
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import messages
-from bot.utils.time import time_since
+from bot.utils import messages, time
log = get_logger(__name__)
@@ -49,7 +48,7 @@ class Utils(Cog):
self.bot = bot
@command()
- @in_whitelist(channels=(Channels.bot_commands, Channels.discord_py), roles=STAFF_PARTNERS_COMMUNITY_ROLES)
+ @in_whitelist(channels=(Channels.bot_commands, Channels.discord_bots), roles=STAFF_PARTNERS_COMMUNITY_ROLES)
async def charinfo(self, ctx: Context, *, characters: str) -> None:
"""Shows you information on up to 50 unicode characters."""
match = re.match(r"<(a?):(\w+):(\d+)>", characters)
@@ -173,7 +172,7 @@ class Utils(Cog):
lines = []
for snowflake in snowflakes:
created_at = snowflake_time(snowflake)
- lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at)}).")
+ lines.append(f"**{snowflake}**\nCreated at {created_at} ({time.format_relative(created_at)}).")
await LinePaginator.paginate(
lines,
@@ -207,6 +206,6 @@ class Utils(Cog):
await message.add_reaction(reaction)
-def setup(bot: Bot) -> None:
+async def setup(bot: Bot) -> None:
"""Load the Utils cog."""
- bot.add_cog(Utils(bot))
+ await bot.add_cog(Utils(bot))
diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py
deleted file mode 100644
index b5c0de8d9..000000000
--- a/bot/monkey_patches.py
+++ /dev/null
@@ -1,75 +0,0 @@
-from datetime import timedelta
-
-import arrow
-from discord import Forbidden, http
-from discord.ext import commands
-
-from bot.log import get_logger
-from bot.utils.regex import MESSAGE_ID_RE
-
-log = get_logger(__name__)
-
-
-class Command(commands.Command):
- """
- A `discord.ext.commands.Command` subclass which supports root aliases.
-
- A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
- top-level commands rather than being aliases of the command's group. It's stored as an attribute
- also named `root_aliases`.
- """
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.root_aliases = kwargs.get("root_aliases", [])
-
- if not isinstance(self.root_aliases, (list, tuple)):
- raise TypeError("Root aliases of a command must be a list or a tuple of strings.")
-
-
-def patch_typing() -> None:
- """
- Sometimes discord turns off typing events by throwing 403's.
-
- Handle those issues by patching the trigger_typing method so it ignores 403's in general.
- """
- log.debug("Patching send_typing, which should fix things breaking when discord disables typing events. Stay safe!")
-
- original = http.HTTPClient.send_typing
- last_403 = None
-
- async def honeybadger_type(self, channel_id: int) -> None: # noqa: ANN001
- nonlocal last_403
- if last_403 and (arrow.utcnow() - last_403) < timedelta(minutes=5):
- log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.")
- return
- try:
- await original(self, channel_id)
- except Forbidden:
- last_403 = arrow.utcnow()
- log.warning("Got a 403 from typing event!")
- pass
-
- http.HTTPClient.send_typing = honeybadger_type
-
-
-class FixedPartialMessageConverter(commands.PartialMessageConverter):
- """
- Make the Message converter infer channelID from the given context if only a messageID is given.
-
- Discord.py's Message converter is supposed to infer channelID based
- on ctx.channel if only a messageID is given. A refactor commit, linked below,
- a few weeks before d.py's archival broke this defined behaviour of the converter.
- Currently, if only a messageID is given to the converter, it will only find that message
- if it's in the bot's cache.
-
- https://github.com/Rapptz/discord.py/commit/1a4e73d59932cdbe7bf2c281f25e32529fc7ae1f
- """
-
- @staticmethod
- def _get_id_matches(ctx: commands.Context, argument: str) -> tuple[int, int, int]:
- """Inserts ctx.channel.id before calling super method if argument is just a messageID."""
- match = MESSAGE_ID_RE.match(argument)
- if match:
- argument = f"{ctx.channel.id}-{match.group('message_id')}"
- return commands.PartialMessageConverter._get_id_matches(ctx, argument)
diff --git a/bot/resources/tags/contribute.md b/bot/resources/tags/contribute.md
index 070975646..50c5cd11f 100644
--- a/bot/resources/tags/contribute.md
+++ b/bot/resources/tags/contribute.md
@@ -7,6 +7,6 @@ Looking to contribute to Open Source Projects for the first time? Want to add a
• [Site](https://github.com/python-discord/site) - resources, guides, and more
**Where to start**
-1. Read our [contributing guidelines](https://pythondiscord.com/pages/guides/pydis-guides/contributing/)
+1. Read our [contribution guide](https://pythondiscord.com/pages/guides/pydis-guides/contributing/)
2. Chat with us in <#635950537262759947> if you're ready to jump in or have any questions
3. Open an issue or ask to be assigned to an issue to work on
diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md
index 6c8018761..75fbe0f8a 100644
--- a/bot/resources/tags/dictcomps.md
+++ b/bot/resources/tags/dictcomps.md
@@ -11,4 +11,4 @@ One can use a dict comp to change an existing dictionary using its `items` metho
>>> {key.upper(): value * 2 for key, value in first_dict.items()}
{'I': 2, 'LOVE': 8, 'PYTHON': 12}
```
-For more information and examples, check out [PEP 274](https://www.python.org/dev/peps/pep-0274/)
+For more information and examples, check out [PEP 274](https://peps.python.org/pep-0274/)
diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md
index 20043131e..6e9d9aa09 100644
--- a/bot/resources/tags/docstring.md
+++ b/bot/resources/tags/docstring.md
@@ -15,4 +15,4 @@ You can get the docstring by using the [`inspect.getdoc`](https://docs.python.or
For the last example, you can print it by doing this: `print(inspect.getdoc(greet))`.
-For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [official docstring specification](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring).
+For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [official docstring specification](https://peps.python.org/pep-0257/#what-is-a-docstring).
diff --git a/bot/resources/tags/enumerate.md b/bot/resources/tags/enumerate.md
index dd984af52..da9c86a36 100644
--- a/bot/resources/tags/enumerate.md
+++ b/bot/resources/tags/enumerate.md
@@ -10,4 +10,4 @@ into beautiful, _pythonic_ code:
for index, item in enumerate(my_list):
print(f"{index}: {item}")
```
-For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://www.python.org/dev/peps/pep-0279/).
+For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://peps.python.org/pep-0279/).
diff --git a/bot/resources/tags/indent.md b/bot/resources/tags/indent.md
index dec8407b0..4c3cdd126 100644
--- a/bot/resources/tags/indent.md
+++ b/bot/resources/tags/indent.md
@@ -16,9 +16,9 @@ The first line is not indented. The next two lines are indented to be inside of
**Indentation is used after:**
**1.** [Compound statements](https://docs.python.org/3/reference/compound_stmts.html) (eg. `if`, `while`, `for`, `try`, `with`, `def`, `class`, and their counterparts)
-**2.** [Continuation lines](https://www.python.org/dev/peps/pep-0008/#indentation)
+**2.** [Continuation lines](https://peps.python.org/pep-0008/#indentation)
**More Info**
-**1.** [Indentation style guide](https://www.python.org/dev/peps/pep-0008/#indentation)
-**2.** [Tabs or Spaces?](https://www.python.org/dev/peps/pep-0008/#tabs-or-spaces)
+**1.** [Indentation style guide](https://peps.python.org/pep-0008/#indentation)
+**2.** [Tabs or Spaces?](https://peps.python.org/pep-0008/#tabs-or-spaces)
**3.** [Official docs on indentation](https://docs.python.org/3/reference/lexical_analysis.html#indentation)
diff --git a/bot/resources/tags/intents.md b/bot/resources/tags/intents.md
index 464caf0ba..aa49d59ae 100644
--- a/bot/resources/tags/intents.md
+++ b/bot/resources/tags/intents.md
@@ -1,6 +1,6 @@
**Using intents in discord.py**
-Intents are a feature of Discord that tells the gateway exactly which events to send your bot. By default, discord.py has all intents enabled, except for the `Members` and `Presences` intents, which are needed for events such as `on_member` and to get members' statuses.
+Intents are a feature of Discord that tells the gateway exactly which events to send your bot. By default discord.py has all intents enabled except for `Members`, `Message Content`, and `Presences`. These are needed for features such as `on_member` events, to get access to message content, and to get members' statuses.
To enable one of these intents, you need to first go to the [Discord developer portal](https://discord.com/developers/applications), then to the bot page of your bot's application. Scroll down to the `Privileged Gateway Intents` section, then enable the intents that you need.
diff --git a/bot/resources/tags/off-topic-names.md b/bot/resources/tags/off-topic-names.md
new file mode 100644
index 000000000..5d0614aaa
--- /dev/null
+++ b/bot/resources/tags/off-topic-names.md
@@ -0,0 +1,10 @@
+**Off-topic channels**
+
+There are three off-topic channels:
+• <#291284109232308226>
+• <#463035241142026251>
+• <#463035268514185226>
+
+The channel names change every night at midnight UTC and are often fun meta references to jokes or conversations that happened on the server.
+
+See our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) page for more guidance on how the channels should be used.
diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md
deleted file mode 100644
index 287224d7f..000000000
--- a/bot/resources/tags/off-topic.md
+++ /dev/null
@@ -1,10 +0,0 @@
-**Off-topic channels**
-
-There are three off-topic channels:
-• <#463035268514185226>
-• <#463035241142026251>
-• <#291284109232308226>
-
-Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list.
-
-Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations.
diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md
index d75a73d78..25ade8620 100644
--- a/bot/resources/tags/or-gotcha.md
+++ b/bot/resources/tags/or-gotcha.md
@@ -1,5 +1,6 @@
When checking if something is equal to one thing or another, you might think that this is possible:
```py
+# Incorrect...
if favorite_fruit == 'grapefruit' or 'lemon':
print("That's a weird favorite fruit to have.")
```
diff --git a/bot/resources/tags/ot.md b/bot/resources/tags/ot.md
new file mode 100644
index 000000000..636e59110
--- /dev/null
+++ b/bot/resources/tags/ot.md
@@ -0,0 +1,3 @@
+**Off-topic channel:** <#463035268514185226>
+
+Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations.
diff --git a/bot/resources/tags/pathlib.md b/bot/resources/tags/pathlib.md
index dfeb7ecac..24ca895d8 100644
--- a/bot/resources/tags/pathlib.md
+++ b/bot/resources/tags/pathlib.md
@@ -18,4 +18,4 @@ Python 3 comes with a new module named `Pathlib`. Since Python 3.6, `pathlib.Pat
• [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
• [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
• [**Official Documentation**](https://docs.python.org/3/library/pathlib.html)
-• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/)
+• [**PEP 519** - Adding a file system path protocol](https://peps.python.org/pep-0519/)
diff --git a/bot/resources/tags/pep8.md b/bot/resources/tags/pep8.md
index 57b176122..a2510d697 100644
--- a/bot/resources/tags/pep8.md
+++ b/bot/resources/tags/pep8.md
@@ -1,5 +1,5 @@
**PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like flake8 to verify that the code they're writing complies with the style guide.
More information:
-• [PEP 8 document](https://www.python.org/dev/peps/pep-0008)
+• [PEP 8 document](https://peps.python.org/pep-0008/)
• [Our PEP 8 song!](https://www.youtube.com/watch?v=hgI0p1zf31k) :notes:
diff --git a/bot/resources/tags/positional-keyword.md b/bot/resources/tags/positional-keyword.md
index dd6ddfc4b..d6b4e0cd4 100644
--- a/bot/resources/tags/positional-keyword.md
+++ b/bot/resources/tags/positional-keyword.md
@@ -19,7 +19,7 @@ def sum(a, b=1):
sum(1, b=5)
sum(1, 5) # same as above
```
-[Somtimes this is forced](https://www.python.org/dev/peps/pep-0570/#history-of-positional-only-parameter-semantics-in-python), in the case of the `pow()` function.
+[Somtimes this is forced](https://peps.python.org/pep-0570/#history-of-positional-only-parameter-semantics-in-python), in the case of the `pow()` function.
The reverse is also true:
```py
@@ -33,6 +33,6 @@ The reverse is also true:
```
**More info**
-• [Keyword only arguments](https://www.python.org/dev/peps/pep-3102/)
-• [Positional only arguments](https://www.python.org/dev/peps/pep-0570/)
+• [Keyword only arguments](https://peps.python.org/pep-3102/)
+• [Positional only arguments](https://peps.python.org/pep-0570/)
• `!tags param-arg` (Parameters vs. Arguments)
diff --git a/bot/resources/tags/quotes.md b/bot/resources/tags/quotes.md
index 8421748a1..99ce93f61 100644
--- a/bot/resources/tags/quotes.md
+++ b/bot/resources/tags/quotes.md
@@ -16,5 +16,5 @@ Example:
If you need both single and double quotes inside your string, use the version that would result in the least amount of escapes. In the case of a tie, use the quotation you use the most.
**References:**
-• [pep-8 on quotes](https://www.python.org/dev/peps/pep-0008/#string-quotes)
-• [convention for triple quoted strings](https://www.python.org/dev/peps/pep-0257/)
+• [pep-8 on quotes](https://peps.python.org/pep-0008/#string-quotes)
+• [convention for triple quoted strings](https://peps.python.org/pep-0257/)
diff --git a/bot/resources/tags/regex.md b/bot/resources/tags/regex.md
new file mode 100644
index 000000000..35fee45a9
--- /dev/null
+++ b/bot/resources/tags/regex.md
@@ -0,0 +1,15 @@
+**Regular expressions**
+Regular expressions (regex) are a tool for finding patterns in strings. The standard library's `re` module defines functions for using regex patterns.
+
+**Example**
+We can use regex to pull out all the numbers in a sentence:
+```py
+>>> import re
+>>> x = "On Oct 18 1963 a cat was launched aboard rocket #47"
+>>> regex_pattern = r"[0-9]{1,3}" # Matches 1-3 digits
+>>> re.findall(regex_pattern, foo)
+['18', '196', '3', '47'] # Notice the year is cut off
+```
+**See Also**
+• [The re docs](https://docs.python.org/3/library/re.html) - for functions that use regex
+• [regex101.com](https://regex101.com) - an interactive site for testing your regular expression
diff --git a/bot/resources/tags/resources.md b/bot/resources/tags/resources.md
deleted file mode 100644
index 201e0eb1e..000000000
--- a/bot/resources/tags/resources.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-embed:
- title: "Resources"
----
-
-The [Resources page](https://www.pythondiscord.com/resources/) on our website contains a list of hand-selected learning resources that we regularly recommend to both beginners and experts.
diff --git a/bot/resources/tags/sql-fstring.md b/bot/resources/tags/sql-fstring.md
index 94dd870fd..538a0aa87 100644
--- a/bot/resources/tags/sql-fstring.md
+++ b/bot/resources/tags/sql-fstring.md
@@ -13,4 +13,4 @@ Note: Different database libraries support different placeholder styles, e.g. `%
**See Also**
• [Extended Example with SQLite](https://docs.python.org/3/library/sqlite3.html) (search for "Instead, use the DB-API's parameter substitution")
-• [PEP-249](https://www.python.org/dev/peps/pep-0249) - A specification of how database libraries in Python should work
+• [PEP-249](https://peps.python.org/pep-0249/) - A specification of how database libraries in Python should work
diff --git a/bot/resources/tags/star-imports.md b/bot/resources/tags/star-imports.md
index 3b1b6a858..6e20e2b09 100644
--- a/bot/resources/tags/star-imports.md
+++ b/bot/resources/tags/star-imports.md
@@ -36,4 +36,4 @@ Conclusion: Namespaces are one honking great idea -- let's do more of those! *[3
**[1]** If the module defines the variable `__all__`, the names defined in `__all__` will get imported by the wildcard import, otherwise all the names in the module get imported (except for names with a leading underscore)
**[2]** [Namespaces and scopes](https://www.programiz.com/python-programming/namespace)
-**[3]** [Zen of Python](https://www.python.org/dev/peps/pep-0020/)
+**[3]** [Zen of Python](https://peps.python.org/pep-0020/)
diff --git a/bot/resources/tags/strip-gotcha.md b/bot/resources/tags/strip-gotcha.md
new file mode 100644
index 000000000..9ad495cd2
--- /dev/null
+++ b/bot/resources/tags/strip-gotcha.md
@@ -0,0 +1,17 @@
+When working with `strip`, `lstrip`, or `rstrip`, you might think that this would be the case:
+```py
+>>> "Monty Python".rstrip(" Python")
+"Monty"
+```
+While this seems intuitive, it would actually result in:
+```py
+"M"
+```
+as Python interprets the argument to these functions as a set of characters rather than a substring.
+
+If you want to remove a prefix/suffix from a string, `str.removeprefix` and `str.removesuffix` are recommended and were added in 3.9.
+```py
+>>> "Monty Python".removesuffix(" Python")
+"Monty"
+```
+See the documentation of [str.removeprefix](https://docs.python.org/3.10/library/stdtypes.html#str.removeprefix) and [str.removesuffix](https://docs.python.org/3.10/library/stdtypes.html#str.removesuffix) for more information.
diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md
index 321737aac..e21fa6c6e 100644
--- a/bot/resources/tags/traceback.md
+++ b/bot/resources/tags/traceback.md
@@ -1,18 +1,15 @@
Please provide the full traceback for your exception in order to help us identify your issue.
+While the last line of the error message tells us what kind of error you got,
+the full traceback will tell us which line, and other critical information to solve your problem.
+Please avoid screenshots so we can copy and paste parts of the message.
A full traceback could look like:
```py
Traceback (most recent call last):
- File "tiny", line 3, in
- do_something()
- File "tiny", line 2, in do_something
- a = 6 / b
-ZeroDivisionError: division by zero
+ File "my_file.py", line 5, in <module>
+ add_three("6")
+ File "my_file.py", line 2, in add_three
+ a = num + 3
+TypeError: can only concatenate str (not "int") to str
```
-The best way to read your traceback is bottom to top.
-
-• Identify the exception raised (in this case `ZeroDivisionError`)
-• Make note of the line number (in this case `2`), and navigate there in your program.
-• Try to understand why the error occurred (in this case because `b` is `0`).
-
-To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/guides/pydis-guides/asking-good-questions/#examining-tracebacks) or the [official Python tutorial](https://docs.python.org/3.7/tutorial/errors.html).
+If the traceback is long, use [our pastebin](https://paste.pythondiscord.com/).
diff --git a/bot/resources/tags/type-hint.md b/bot/resources/tags/type-hint.md
new file mode 100644
index 000000000..f4a12f125
--- /dev/null
+++ b/bot/resources/tags/type-hint.md
@@ -0,0 +1,19 @@
+**Type Hints**
+
+A type hint indicates what type a variable is expected to be.
+```python
+def add(a: int, b: int) -> int:
+ return a + b
+```
+The type hints indicate that for our `add` function the parameters `a` and `b` should be integers, and the function should return an integer when called.
+
+It's important to note these are just hints and are not enforced at runtime.
+
+```python
+add("hello ", "world")
+```
+The above code won't error even though it doesn't follow the function's type hints; the two strings will be concatenated as normal.
+
+Third party tools like [mypy](https://mypy.readthedocs.io/en/stable/introduction.html) can validate your code to ensure it is type hinted correctly. This can help you identify potentially buggy code, for example it would error on the second example as our `add` function is not intended to concatenate strings.
+
+[mypy's documentation](https://mypy.readthedocs.io/en/stable/builtin_types.html) contains useful information on type hinting, and for more information check out [this documentation page](https://typing.readthedocs.io/en/latest/index.html).
diff --git a/bot/resources/tags/with.md b/bot/resources/tags/with.md
index 62d5612f2..83f160b4f 100644
--- a/bot/resources/tags/with.md
+++ b/bot/resources/tags/with.md
@@ -5,4 +5,4 @@ with open("test.txt", "r") as file:
```
The above code automatically closes `file` when the `with` block exits, so you never have to manually do a `file.close()`. Most connection types, including file readers and database connections, support this.
-For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://www.python.org/dev/peps/pep-0343/).
+For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://peps.python.org/pep-0343/).
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 13533a467..567821126 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,4 +1,12 @@
from bot.utils.helpers import CogABCMeta, find_nth_occurrence, has_lines, pad_base64
-from bot.utils.services import send_to_paste_service
+from bot.utils.services import PasteTooLongError, PasteUploadError, send_to_paste_service
-__all__ = ['CogABCMeta', 'find_nth_occurrence', 'has_lines', 'pad_base64', 'send_to_paste_service']
+__all__ = [
+ 'CogABCMeta',
+ 'find_nth_occurrence',
+ 'has_lines',
+ 'pad_base64',
+ 'send_to_paste_service',
+ 'PasteUploadError',
+ 'PasteTooLongError',
+]
diff --git a/bot/utils/extensions.py b/bot/utils/extensions.py
deleted file mode 100644
index 50350ea8d..000000000
--- a/bot/utils/extensions.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import importlib
-import inspect
-import pkgutil
-from typing import Iterator, NoReturn
-
-from bot import exts
-
-
-def unqualify(name: str) -> str:
- """Return an unqualified name given a qualified module/package `name`."""
- return name.rsplit(".", maxsplit=1)[-1]
-
-
-def walk_extensions() -> Iterator[str]:
- """Yield extension names from the bot.exts subpackage."""
-
- def on_error(name: str) -> NoReturn:
- raise ImportError(name=name) # pragma: no cover
-
- for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error):
- if unqualify(module.name).startswith("_"):
- # Ignore module/package names starting with an underscore.
- continue
-
- if module.ispkg:
- imported = importlib.import_module(module.name)
- if not inspect.isfunction(getattr(imported, "setup", None)):
- # If it lacks a setup function, it's not an extension.
- continue
-
- yield module.name
-
-
-EXTENSIONS = frozenset(walk_extensions())
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index e55c07062..a5ed84351 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -6,12 +6,12 @@ from io import BytesIO
from typing import Callable, List, Optional, Sequence, Union
import discord
+from botcore.utils import scheduling
from discord.ext.commands import Context
import bot
from bot.constants import Emojis, MODERATION_ROLES, NEGATIVE_REPLIES
from bot.log import get_logger
-from bot.utils import scheduling
log = get_logger(__name__)
diff --git a/bot/utils/regex.py b/bot/utils/regex.py
deleted file mode 100644
index 9dc1eba9d..000000000
--- a/bot/utils/regex.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import re
-
-INVITE_RE = re.compile(
- r"(discord([\.,]|dot)gg|" # Could be discord.gg/
- r"discord([\.,]|dot)com(\/|slash)invite|" # or discord.com/invite/
- r"discordapp([\.,]|dot)com(\/|slash)invite|" # or discordapp.com/invite/
- r"discord([\.,]|dot)me|" # or discord.me
- r"discord([\.,]|dot)li|" # or discord.li
- r"discord([\.,]|dot)io|" # or discord.io.
- r"((?<!\w)([\.,]|dot))gg" # or .gg/
- r")([\/]|slash)" # / or 'slash'
- r"(?P<invite>[a-zA-Z0-9\-]+)", # the invite code itself
- flags=re.IGNORECASE
-)
-MESSAGE_ID_RE = re.compile(r'(?P<message_id>[0-9]{15,20})$')
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
deleted file mode 100644
index 7b4c8e2de..000000000
--- a/bot/utils/scheduling.py
+++ /dev/null
@@ -1,192 +0,0 @@
-import asyncio
-import contextlib
-import inspect
-import typing as t
-from datetime import datetime
-from functools import partial
-
-from bot.log import get_logger
-
-
-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):
- self.name = name
-
- self._log = get_logger(f"{__name__}.{name}")
- self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {}
-
- def __contains__(self, task_id: t.Hashable) -> bool:
- """Return True if a task with the given `task_id` is currently scheduled."""
- return task_id in self._scheduled_tasks
-
- def schedule(self, task_id: t.Hashable, coroutine: t.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.
- """
- 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: t.Hashable, coroutine: t.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.
- """
- 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: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None:
- """
- Schedule `coroutine` to be executed after the given `delay` number of 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.
- """
- self.schedule(task_id, self._await_later(delay, task_id, coroutine))
-
- def cancel(self, task_id: t.Hashable) -> None:
- """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist."""
- 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: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None:
- """Await `coroutine` after the given `delay` number of 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: t.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: t.Awaitable,
- *,
- suppressed_exceptions: tuple[t.Type[Exception]] = (),
- event_loop: t.Optional[asyncio.AbstractEventLoop] = None,
- **kwargs,
-) -> asyncio.Task:
- """
- Wrapper for creating asyncio `Task`s 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 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: t.Tuple[t.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 = get_logger(__name__)
- log.error(f"Error in task {task.get_name()} {id(task)}!", exc_info=exception)
diff --git a/bot/utils/services.py b/bot/utils/services.py
index 439c8d500..a752ac0ec 100644
--- a/bot/utils/services.py
+++ b/bot/utils/services.py
@@ -1,5 +1,3 @@
-from typing import Optional
-
from aiohttp import ClientConnectorError
import bot
@@ -9,18 +7,40 @@ from bot.log import get_logger
log = get_logger(__name__)
FAILED_REQUEST_ATTEMPTS = 3
+MAX_PASTE_LENGTH = 100_000
+
+
+class PasteUploadError(Exception):
+ """Raised when an error is encountered uploading to the paste service."""
+
+
+class PasteTooLongError(Exception):
+ """Raised when content is too large to upload to the paste service."""
-async def send_to_paste_service(contents: str, *, extension: str = "") -> Optional[str]:
+async def send_to_paste_service(contents: str, *, extension: str = "", max_length: int = MAX_PASTE_LENGTH) -> str:
"""
Upload `contents` to the paste service.
- `extension` is added to the output URL
+ Add `extension` to the output URL. Use `max_length` to limit the allowed contents length
+ to lower than the maximum allowed by the paste service.
- When an error occurs, `None` is returned, otherwise the generated URL with the suffix.
+ Raise `ValueError` if `max_length` is greater than the maximum allowed by the paste service.
+ Raise `PasteTooLongError` if `contents` is too long to upload, and `PasteUploadError` if uploading fails.
+
+ Return the generated URL with the extension.
"""
+ if max_length > MAX_PASTE_LENGTH:
+ raise ValueError(f"`max_length` must not be greater than {MAX_PASTE_LENGTH}")
+
extension = extension and f".{extension}"
- log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.")
+
+ contents_size = len(contents.encode())
+ if contents_size > max_length:
+ log.info("Contents too large to send to paste service.")
+ raise PasteTooLongError(f"Contents of size {contents_size} greater than maximum size {max_length}")
+
+ log.debug(f"Sending contents of size {contents_size} bytes to paste service.")
paste_url = URLs.paste_service.format(key="documents")
for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1):
try:
@@ -59,3 +79,5 @@ async def send_to_paste_service(contents: str, *, extension: str = "") -> Option
f"Got unexpected JSON response from paste service: {response_json}\n"
f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
)
+
+ raise PasteUploadError("Failed to upload contents to paste service")
diff --git a/bot/utils/time.py b/bot/utils/time.py
index eaa9b72e9..a0379c3ef 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -1,15 +1,12 @@
import datetime
import re
from enum import Enum
-from typing import Optional, Union
+from time import struct_time
+from typing import Literal, Optional, Union, overload
import arrow
-import dateutil.parser
from dateutil.relativedelta import relativedelta
-RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
-DISCORD_TIMESTAMP_REGEX = re.compile(r"<t:(\d+):f>")
-
_DURATION_REGEX = re.compile(
r"((?P<years>\d+?) ?(years|year|Y|y) ?)?"
r"((?P<months>\d+?) ?(months|month|m) ?)?"
@@ -20,8 +17,19 @@ _DURATION_REGEX = re.compile(
r"((?P<seconds>\d+?) ?(seconds|second|S|s))?"
)
-
-ValidTimestamp = Union[int, datetime.datetime, datetime.date, datetime.timedelta, relativedelta]
+# All supported types for the single-argument overload of arrow.get(). tzinfo is excluded because
+# it's too implicit of a way for the caller to specify that they want the current time.
+Timestamp = Union[
+ arrow.Arrow,
+ datetime.datetime,
+ datetime.date,
+ struct_time,
+ int, # POSIX timestamp
+ float, # POSIX timestamp
+ str, # ISO 8601-formatted string
+ tuple[int, int, int], # ISO calendar tuple
+]
+_Precision = Literal["years", "months", "days", "hours", "minutes", "seconds"]
class TimestampFormats(Enum):
@@ -42,7 +50,7 @@ class TimestampFormats(Enum):
def _stringify_time_unit(value: int, unit: str) -> str:
"""
- Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit.
+ Return a string to represent a value and time unit, ensuring the unit's correct plural form is used.
>>> _stringify_time_unit(1, "seconds")
"1 second"
@@ -61,33 +69,140 @@ def _stringify_time_unit(value: int, unit: str) -> str:
return f"{value} {unit}"
-def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str:
- """Create and format a Discord flavored markdown timestamp."""
- if format not in TimestampFormats:
- raise ValueError(f"Format can only be one of {', '.join(TimestampFormats.args)}, not {format}.")
+def discord_timestamp(timestamp: Timestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str:
+ """
+ Format a timestamp as a Discord-flavored Markdown timestamp.
+
+ `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`.
+ """
+ timestamp = int(arrow.get(timestamp).timestamp())
+ return f"<t:{timestamp}:{format.value}>"
+
+
+# region humanize_delta overloads
+@overload
+def humanize_delta(
+ arg1: Union[relativedelta, Timestamp],
+ /,
+ *,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+
+
+@overload
+def humanize_delta(
+ end: Timestamp,
+ start: Timestamp,
+ /,
+ *,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+
+
+@overload
+def humanize_delta(
+ *,
+ years: int = 0,
+ months: int = 0,
+ weeks: float = 0,
+ days: float = 0,
+ hours: float = 0,
+ minutes: float = 0,
+ seconds: float = 0,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+# endregion
+
+
+def humanize_delta(
+ *args,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+ **kwargs,
+) -> str:
+ """
+ Return a human-readable version of a time duration.
+
+ `precision` is the smallest unit of time to include (e.g. "seconds", "minutes").
- # Convert each possible timestamp class to an integer.
- if isinstance(timestamp, datetime.datetime):
- timestamp = (timestamp - arrow.get(0)).total_seconds()
- elif isinstance(timestamp, datetime.date):
- timestamp = (timestamp - arrow.get(0)).total_seconds()
- elif isinstance(timestamp, datetime.timedelta):
- timestamp = timestamp.total_seconds()
- elif isinstance(timestamp, relativedelta):
- timestamp = timestamp.seconds
+ `max_units` is the maximum number of units of time to include.
+ Count units from largest to smallest (e.g. count days before months).
- return f"<t:{int(timestamp)}:{format.value}>"
+ Use the absolute value of the duration if `absolute` is True.
+ Usage:
-def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str:
- """
- Returns a human-readable version of the relativedelta.
+ Keyword arguments specifying values for time units, to construct a `relativedelta` and humanize
+ the duration represented by it:
+
+ >>> humanize_delta(days=2, hours=16, seconds=23)
+ '2 days, 16 hours and 23 seconds'
+
+ **One** `relativedelta` object, to humanize the duration represented by it:
+
+ >>> humanize_delta(relativedelta(years=12, months=6))
+ '12 years and 6 months'
+
+ Note that `leapdays` and absolute info (singular names) will be ignored during humanization.
+
+ **One** timestamp of a type supported by the single-arg `arrow.get()`, except for `tzinfo`,
+ to humanize the duration between it and the current time:
+
+ >>> humanize_delta('2021-08-06T12:43:01Z', absolute=True) # now = 2021-08-06T12:33:33Z
+ '9 minutes and 28 seconds'
+
+ >>> humanize_delta('2021-08-06T12:43:01Z', absolute=False) # now = 2021-08-06T12:33:33Z
+ '-9 minutes and -28 seconds'
+
+ **Two** timestamps, each of a type supported by the single-arg `arrow.get()`, except for
+ `tzinfo`, to humanize the duration between them:
+
+ >>> humanize_delta(datetime.datetime(2020, 1, 1), '2021-01-01T12:00:00Z', absolute=False)
+ '1 year and 12 hours'
+
+ >>> humanize_delta('2021-01-01T12:00:00Z', datetime.datetime(2020, 1, 1), absolute=False)
+ '-1 years and -12 hours'
+
+ Note that order of the arguments can result in a different output even if `absolute` is True:
+
+ >>> x = datetime.datetime(3000, 11, 1)
+ >>> y = datetime.datetime(3000, 9, 2)
+ >>> humanize_delta(y, x, absolute=True), humanize_delta(x, y, absolute=True)
+ ('1 month and 30 days', '1 month and 29 days')
- precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").
- max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
+ This is due to the nature of `relativedelta`; it does not represent a fixed period of time.
+ Instead, it's relative to the `datetime` to which it's added to get the other `datetime`.
+ In the example, the difference arises because all months don't have the same number of days.
"""
+ if args and kwargs:
+ raise ValueError("Unsupported combination of positional and keyword arguments.")
+
+ if len(args) == 0:
+ delta = relativedelta(**kwargs)
+ elif len(args) == 1 and isinstance(args[0], relativedelta):
+ delta = args[0]
+ elif len(args) <= 2:
+ end = arrow.get(args[0])
+ start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow()
+
+ delta = relativedelta(end.datetime, start.datetime)
+ if absolute:
+ delta = abs(delta)
+ else:
+ raise ValueError(f"Received {len(args)} positional arguments, but expected 1 or 2.")
+
if max_units <= 0:
- raise ValueError("max_units must be positive")
+ raise ValueError("max_units must be positive.")
units = (
("years", delta.years),
@@ -98,7 +213,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
("seconds", delta.seconds),
)
- # Add the time units that are >0, but stop at accuracy or max_units.
+ # Add the time units that are >0, but stop at precision or max_units.
time_strings = []
unit_count = 0
for unit, value in units:
@@ -109,7 +224,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
if unit == precision or unit_count >= max_units:
break
- # Add the 'and' between the last two units, if necessary
+ # Add the 'and' between the last two units, if necessary.
if len(time_strings) > 1:
time_strings[-1] = f"{time_strings[-2]} and {time_strings[-1]}"
del time_strings[-2]
@@ -123,19 +238,12 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
return humanized
-def get_time_delta(time_string: str) -> str:
- """Returns the time in human-readable time delta format."""
- date_time = dateutil.parser.isoparse(time_string)
- time_delta = time_since(date_time)
-
- return time_delta
-
-
def parse_duration_string(duration: str) -> Optional[relativedelta]:
"""
- Converts a `duration` string to a relativedelta object.
+ Convert a `duration` string to a relativedelta object.
+
+ The following symbols are supported for each unit of time:
- The function supports the following symbols for each unit of time:
- years: `Y`, `y`, `year`, `years`
- months: `m`, `month`, `months`
- weeks: `w`, `W`, `week`, `weeks`
@@ -143,8 +251,9 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]:
- hours: `H`, `h`, `hour`, `hours`
- minutes: `M`, `minute`, `minutes`
- seconds: `S`, `s`, `second`, `seconds`
+
The units need to be provided in descending order of magnitude.
- If the string does represent a durationdelta object, it will return None.
+ Return None if the `duration` string cannot be parsed according to the symbols above.
"""
match = _DURATION_REGEX.fullmatch(duration)
if not match:
@@ -157,76 +266,63 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]:
def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta:
- """Converts a relativedelta object to a timedelta object."""
+ """Convert a relativedelta object to a timedelta object."""
utcnow = arrow.utcnow()
return utcnow + delta - utcnow
-def time_since(past_datetime: datetime.datetime) -> str:
- """Takes a datetime and returns a discord timestamp that describes how long ago that datetime was."""
- return discord_timestamp(past_datetime, TimestampFormats.RELATIVE)
-
-
-def parse_rfc1123(stamp: str) -> datetime.datetime:
- """Parse RFC1123 time string into datetime."""
- return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
+def format_relative(timestamp: Timestamp) -> str:
+ """
+ Format `timestamp` as a relative Discord timestamp.
+ A relative timestamp describes how much time has elapsed since `timestamp` or how much time
+ remains until `timestamp` is reached.
-def format_infraction(timestamp: str) -> str:
- """Format an infraction timestamp to a discord timestamp."""
- return discord_timestamp(dateutil.parser.isoparse(timestamp))
+ `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`.
+ """
+ return discord_timestamp(timestamp, TimestampFormats.RELATIVE)
-def format_infraction_with_duration(
- date_to: Optional[str],
- date_from: Optional[datetime.datetime] = None,
+def format_with_duration(
+ timestamp: Optional[Timestamp],
+ other_timestamp: Optional[Timestamp] = None,
max_units: int = 2,
- absolute: bool = True
) -> Optional[str]:
"""
- Return `date_to` formatted as a discord timestamp with the timestamp duration since `date_from`.
+ Return `timestamp` formatted as a discord timestamp with the timestamp duration since `other_timestamp`.
+
+ `timestamp` and `other_timestamp` can be any type supported by the single-arg `arrow.get()`,
+ except for a `tzinfo`. Use the current time if `other_timestamp` is None or unspecified.
- `max_units` specifies the maximum number of units of time to include in the duration. For
- example, a value of 1 may include days but not hours.
+ `max_units` is forwarded to `time.humanize_delta`. See its documentation for more information.
- If `absolute` is True, the absolute value of the duration delta is used. This prevents negative
- values in the case that `date_to` is in the past relative to `date_from`.
+ Return None if `timestamp` is None.
"""
- if not date_to:
+ if timestamp is None:
return None
- date_to_formatted = format_infraction(date_to)
-
- date_from = date_from or datetime.datetime.now(datetime.timezone.utc)
- date_to = dateutil.parser.isoparse(date_to).replace(microsecond=0)
+ if other_timestamp is None:
+ other_timestamp = arrow.utcnow()
- delta = relativedelta(date_to, date_from)
- if absolute:
- delta = abs(delta)
+ formatted_timestamp = discord_timestamp(timestamp)
+ duration = humanize_delta(timestamp, other_timestamp, max_units=max_units)
- duration = humanize_delta(delta, max_units=max_units)
- duration_formatted = f" ({duration})" if duration else ""
+ return f"{formatted_timestamp} ({duration})"
- return f"{date_to_formatted}{duration_formatted}"
-
-def until_expiration(
- expiry: Optional[str]
-) -> Optional[str]:
+def until_expiration(expiry: Optional[Timestamp]) -> str:
"""
- Get the remaining time until infraction's expiration, in a discord timestamp.
+ Get the remaining time until an infraction's expiration as a Discord timestamp.
- Returns a human-readable version of the remaining duration between arrow.utcnow() and an expiry.
- Similar to time_since, except that this function doesn't error on a null input
- and return null if the expiry is in the paste
- """
- if not expiry:
- return None
+ `expiry` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`.
- now = arrow.utcnow()
- since = dateutil.parser.isoparse(expiry).replace(microsecond=0)
+ Return "Permanent" if `expiry` is None. Return "Expired" if `expiry` is in the past.
+ """
+ if expiry is None:
+ return "Permanent"
- if since < now:
- return None
+ expiry = arrow.get(expiry)
+ if expiry < arrow.utcnow():
+ return "Expired"
- return discord_timestamp(since, TimestampFormats.RELATIVE)
+ return format_relative(expiry)
diff --git a/config-default.yml b/config-default.yml
index 1e04f5844..dae923158 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -174,7 +174,7 @@ guild:
how_to_get_help: 704250143020417084
# Topical
- discord_py: 343944376055103488
+ discord_bots: 343944376055103488
# Logs
attachment_log: &ATTACH_LOG 649243850006855680
@@ -513,19 +513,16 @@ help_channels:
# Prefix for help channel names
name_prefix: 'help-'
- # Notify if more available channels are needed but there are no more dormant ones
- notify: true
+ notify_channel: *HELPERS # Channel in which to send notifications messages
+ notify_minutes: 15 # Minimum interval between none_remaining or running_low notifications
- # Channel in which to send notifications
- notify_channel: *HELPERS
-
- # Minimum interval between helper notifications
- notify_minutes: 15
-
- # Mention these roles in notifications
- notify_roles:
+ notify_none_remaining: true # Pinging notification for the Helper role when no dormant channels remain
+ notify_none_remaining_roles: # Mention these roles in the none_remaining notification
- *HELPERS_ROLE
+ notify_running_low: true # Non-pinging notification which is triggered when the channel count is equal or less than the threshold
+ notify_running_low_threshold: 4 # The amount of channels at which a running_low notification will be sent
+
redirect_output:
delete_delay: 15
diff --git a/docker-compose.yml b/docker-compose.yml
index 869d9acb6..ce78f65aa 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -38,6 +38,7 @@ services:
metricity:
<< : *logging
+ << : *restart_policy
restart: on-failure # USE_METRICITY=false will stop the container, so this ensures it only restarts on error
depends_on:
postgres:
diff --git a/poetry.lock b/poetry.lock
index d91941d45..7e74cecdd 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,47 +1,33 @@
[[package]]
-name = "aio-pika"
-version = "6.8.0"
-description = "Wrapper for the aiormq for asyncio and humans."
-category = "main"
-optional = false
-python-versions = ">3.5.*, <4"
-
-[package.dependencies]
-aiormq = ">=3.2.3,<4"
-yarl = "*"
-
-[package.extras]
-develop = ["aiomisc (>=10.1.6,<10.2.0)", "async-generator", "coverage (!=4.3)", "coveralls", "pylava", "pytest", "pytest-cov", "shortuuid", "nox", "sphinx", "sphinx-autobuild", "timeout-decorator", "tox (>=2.4)"]
-
-[[package]]
name = "aiodns"
-version = "2.0.0"
+version = "3.0.0"
description = "Simple DNS resolver for asyncio"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
-pycares = ">=3.0.0"
+pycares = ">=4.0.0"
[[package]]
name = "aiohttp"
-version = "3.7.4.post0"
+version = "3.8.1"
description = "Async http client/server framework (asyncio)"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
-async-timeout = ">=3.0,<4.0"
+aiosignal = ">=1.1.2"
+async-timeout = ">=4.0.0a3,<5.0"
attrs = ">=17.3.0"
-chardet = ">=2.0,<5.0"
+charset-normalizer = ">=2.0,<3.0"
+frozenlist = ">=1.1.1"
multidict = ">=4.5,<7.0"
-typing-extensions = ">=3.6.5"
yarl = ">=1.0,<2.0"
[package.extras]
-speedups = ["aiodns", "brotlipy", "cchardet"]
+speedups = ["aiodns", "brotli", "cchardet"]
[[package]]
name = "aioredis"
@@ -56,23 +42,19 @@ async-timeout = "*"
hiredis = "*"
[[package]]
-name = "aiormq"
-version = "3.3.1"
-description = "Pure python AMQP asynchronous client library"
+name = "aiosignal"
+version = "1.2.0"
+description = "aiosignal: a list of registered asynchronous callbacks"
category = "main"
optional = false
-python-versions = ">3.5.*"
+python-versions = ">=3.6"
[package.dependencies]
-pamqp = "2.3.0"
-yarl = "*"
-
-[package.extras]
-develop = ["aiomisc (>=11.0,<12.0)", "async-generator", "coverage (!=4.3)", "coveralls", "pylava", "pytest", "pytest-cov", "tox (>=2.4)"]
+frozenlist = ">=1.1.0"
[[package]]
name = "arrow"
-version = "1.0.3"
+version = "1.2.2"
description = "Better dates & times for Python"
category = "main"
optional = false
@@ -83,7 +65,7 @@ python-dateutil = ">=2.7.0"
[[package]]
name = "async-rediscache"
-version = "0.1.4"
+version = "0.2.0"
description = "An easy to use asynchronous Redis cache"
category = "main"
optional = false
@@ -91,18 +73,18 @@ python-versions = "~=3.7"
[package.dependencies]
aioredis = ">=1"
-fakeredis = {version = ">=1.3.1", optional = true, markers = "extra == \"fakeredis\""}
+fakeredis = {version = ">=1.4.4", extras = ["lua"], optional = true, markers = "extra == \"fakeredis\""}
[package.extras]
-fakeredis = ["fakeredis (>=1.3.1)"]
+fakeredis = ["fakeredis[lua] (>=1.4.4)"]
[[package]]
name = "async-timeout"
-version = "3.0.1"
+version = "4.0.2"
description = "Timeout context manager for asyncio programs"
category = "main"
optional = false
-python-versions = ">=3.5.3"
+python-versions = ">=3.6"
[[package]]
name = "atomicwrites"
@@ -114,29 +96,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "attrs"
-version = "21.2.0"
+version = "21.4.0"
description = "Classes Without Boilerplate"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
-
-[[package]]
-name = "backports.entry-points-selectable"
-version = "1.1.0"
-description = "Compatibility shim providing selectable entry points for older implementations"
-category = "dev"
-optional = false
-python-versions = ">=2.7"
-
-[package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "beautifulsoup4"
@@ -154,12 +124,31 @@ html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
+name = "bot-core"
+version = "6.4.0"
+description = "Bot-Core provides the core functionality and utilities for the bots of the Python Discord community."
+category = "main"
+optional = false
+python-versions = "3.9.*"
+
+[package.dependencies]
+async-rediscache = {version = "0.2.0", extras = ["fakeredis"], optional = true, markers = "extra == \"async-rediscache\""}
+"discord.py" = {url = "https://github.com/Rapptz/discord.py/archive/5a06fa5f3e28d2b7191722e1a84c541560008aea.zip"}
+statsd = "3.3.0"
+
+[package.extras]
+async-rediscache = ["async-rediscache[fakeredis] (==0.2.0)"]
+
+[package.source]
+type = "url"
+url = "https://github.com/python-discord/bot-core/archive/refs/tags/v7.0.0.zip"
+[[package]]
name = "certifi"
-version = "2021.10.8"
+version = "2022.5.18.1"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
-python-versions = "*"
+python-versions = ">=3.6"
[[package]]
name = "cffi"
@@ -181,18 +170,10 @@ optional = false
python-versions = ">=3.6.1"
[[package]]
-name = "chardet"
-version = "4.0.0"
-description = "Universal encoding detector for Python 2 and 3"
-category = "main"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-
-[[package]]
name = "charset-normalizer"
-version = "2.0.7"
+version = "2.0.12"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
-category = "dev"
+category = "main"
optional = false
python-versions = ">=3.5.0"
@@ -209,58 +190,59 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "coloredlogs"
-version = "14.3"
+version = "15.0.1"
description = "Colored terminal output for Python's logging module"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.dependencies]
-humanfriendly = ">=7.1"
+humanfriendly = ">=9.1"
[package.extras]
cron = ["capturer (>=2.4)"]
[[package]]
name = "coverage"
-version = "5.5"
+version = "6.3.2"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+python-versions = ">=3.7"
+
+[package.dependencies]
+tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
[package.extras]
-toml = ["toml"]
+toml = ["tomli"]
[[package]]
-name = "coveralls"
-version = "2.2.0"
-description = "Show coverage stats online via coveralls.io"
-category = "dev"
+name = "deepdiff"
+version = "5.7.0"
+description = "Deep Difference and Search of any Python object/data."
+category = "main"
optional = false
-python-versions = ">= 3.5"
+python-versions = ">=3.6"
[package.dependencies]
-coverage = ">=4.1,<6.0"
-docopt = ">=0.6.1"
-requests = ">=1.0.0"
+ordered-set = "4.0.2"
[package.extras]
-yaml = ["PyYAML (>=3.10)"]
+cli = ["click (==8.0.3)", "pyyaml (==5.4.1)", "toml (==0.10.2)", "clevercsv (==0.7.1)"]
[[package]]
-name = "deepdiff"
-version = "4.3.2"
-description = "Deep Difference and Search of any Python object/data."
+name = "deprecated"
+version = "1.2.13"
+description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
category = "main"
optional = false
-python-versions = ">=3.5"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies]
-ordered-set = ">=3.1.1"
+wrapt = ">=1.10,<2"
[package.extras]
-murmur = ["mmh3"]
+dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"]
[[package]]
name = "discord.py"
@@ -271,35 +253,29 @@ optional = false
python-versions = ">=3.8.0"
[package.dependencies]
-aiohttp = ">=3.6.0,<3.8.0"
+aiohttp = ">=3.6.0,<4"
[package.extras]
-docs = ["sphinx (==4.0.2)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport"]
+docs = ["sphinx (==4.4.0)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport", "typing-extensions"]
speed = ["orjson (>=3.5.4)"]
-voice = ["PyNaCl (>=1.3.0,<1.5)"]
+test = ["coverage", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock"]
+voice = ["PyNaCl (>=1.3.0,<1.6)"]
[package.source]
type = "url"
-url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"
+url = "https://github.com/Rapptz/discord.py/archive/5a06fa5f3e28d2b7191722e1a84c541560008aea.zip"
+
[[package]]
name = "distlib"
-version = "0.3.3"
+version = "0.3.4"
description = "Distribution utilities"
category = "dev"
optional = false
python-versions = "*"
[[package]]
-name = "docopt"
-version = "0.6.2"
-description = "Pythonic argument parser, that will make you smile"
-category = "dev"
-optional = false
-python-versions = "*"
-
-[[package]]
name = "emoji"
-version = "0.6.0"
+version = "1.7.0"
description = "Emoji for Python"
category = "main"
optional = false
@@ -321,15 +297,16 @@ testing = ["pre-commit"]
[[package]]
name = "fakeredis"
-version = "1.6.1"
+version = "1.7.5"
description = "Fake implementation of redis API for testing purposes."
category = "main"
optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.7"
[package.dependencies]
+lupa = {version = "*", optional = true, markers = "extra == \"lua\""}
packaging = "*"
-redis = "<3.6.0"
+redis = "<=4.3.1"
six = ">=1.12"
sortedcontainers = "*"
@@ -350,11 +327,11 @@ sgmllib3k = "*"
[[package]]
name = "filelock"
-version = "3.3.1"
+version = "3.7.0"
description = "A platform independent file lock."
-category = "dev"
+category = "main"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
@@ -362,31 +339,32 @@ testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-co
[[package]]
name = "flake8"
-version = "3.9.2"
+version = "4.0.1"
description = "the modular source code checker: pep8 pyflakes and co"
category = "dev"
optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+python-versions = ">=3.6"
[package.dependencies]
mccabe = ">=0.6.0,<0.7.0"
-pycodestyle = ">=2.7.0,<2.8.0"
-pyflakes = ">=2.3.0,<2.4.0"
+pycodestyle = ">=2.8.0,<2.9.0"
+pyflakes = ">=2.4.0,<2.5.0"
[[package]]
name = "flake8-annotations"
-version = "2.7.0"
+version = "2.8.0"
description = "Flake8 Type Annotation Checks"
category = "dev"
optional = false
-python-versions = ">=3.6.2,<4.0.0"
+python-versions = ">=3.7,<4.0"
[package.dependencies]
-flake8 = ">=3.7,<5.0"
+attrs = ">=21.4,<22.0"
+flake8 = ">=3.7"
[[package]]
name = "flake8-bugbear"
-version = "20.11.1"
+version = "22.3.23"
description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."
category = "dev"
optional = false
@@ -397,7 +375,7 @@ attrs = ">=19.2.0"
flake8 = ">=3.0.0"
[package.extras]
-dev = ["coverage", "black", "hypothesis", "hypothesmith"]
+dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit"]
[[package]]
name = "flake8-docstrings"
@@ -451,14 +429,14 @@ flake8 = "*"
[[package]]
name = "flake8-tidy-imports"
-version = "4.5.0"
+version = "4.6.0"
description = "A flake8 plugin that helps you write tidier imports."
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.dependencies]
-flake8 = ">=3.8.0,<5"
+flake8 = ">=3.8.0"
[[package]]
name = "flake8-todo"
@@ -472,6 +450,14 @@ python-versions = "*"
pycodestyle = ">=2.0.0,<3.0.0"
[[package]]
+name = "frozenlist"
+version = "1.3.0"
+description = "A list-like structure which implements collections.abc.MutableSequence"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
name = "hiredis"
version = "2.0.0"
description = "Python wrapper for hiredis"
@@ -492,14 +478,14 @@ pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_ve
[[package]]
name = "identify"
-version = "2.3.0"
+version = "2.5.1"
description = "File identification library for Python"
category = "dev"
optional = false
-python-versions = ">=3.6.1"
+python-versions = ">=3.7"
[package.extras]
-license = ["editdistance-s"]
+license = ["ukkonen"]
[[package]]
name = "idna"
@@ -519,7 +505,7 @@ python-versions = "*"
[[package]]
name = "isort"
-version = "5.9.3"
+version = "5.10.1"
description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false
@@ -532,8 +518,24 @@ colors = ["colorama (>=0.4.3,<0.5.0)"]
plugins = ["setuptools"]
[[package]]
+name = "jarowinkler"
+version = "1.0.2"
+description = "library for fast approximate string matching using Jaro and Jaro-Winkler similarity"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "lupa"
+version = "1.13"
+description = "Python wrapper around Lua and LuaJIT"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
name = "lxml"
-version = "4.6.3"
+version = "4.8.0"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
category = "main"
optional = false
@@ -567,7 +569,7 @@ python-versions = "*"
[[package]]
name = "more-itertools"
-version = "8.10.0"
+version = "8.12.0"
description = "More routines for operating on iterables, beyond itertools"
category = "main"
optional = false
@@ -583,11 +585,11 @@ python-versions = ">=3.5"
[[package]]
name = "multidict"
-version = "5.2.0"
+version = "6.0.2"
description = "multidict implementation"
category = "main"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[[package]]
name = "nodeenv"
@@ -607,25 +609,14 @@ python-versions = ">=3.5"
[[package]]
name = "packaging"
-version = "21.0"
+version = "21.3"
description = "Core utilities for Python packages"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
-pyparsing = ">=2.0.2"
-
-[[package]]
-name = "pamqp"
-version = "2.3.0"
-description = "RabbitMQ Focused AMQP low-level library"
-category = "main"
-optional = false
-python-versions = "*"
-
-[package.extras]
-codegen = ["lxml"]
+pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "pep8-naming"
@@ -655,15 +646,15 @@ test = ["docutils", "pytest-cov", "pytest-pycodestyle", "pytest-runner"]
[[package]]
name = "platformdirs"
-version = "2.4.0"
+version = "2.5.2"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.extras]
-docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"]
-test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"]
+docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
+test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
[[package]]
name = "pluggy"
@@ -679,7 +670,7 @@ testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pre-commit"
-version = "2.15.0"
+version = "2.17.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev"
optional = false
@@ -695,14 +686,14 @@ virtualenv = ">=20.0.8"
[[package]]
name = "psutil"
-version = "5.8.0"
+version = "5.9.1"
description = "Cross-platform lib for process and system monitoring in Python."
category = "dev"
optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.extras]
-test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]
+test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"]
[[package]]
name = "ptable"
@@ -714,11 +705,11 @@ python-versions = "*"
[[package]]
name = "py"
-version = "1.10.0"
+version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycares"
@@ -736,15 +727,15 @@ idna = ["idna (>=2.1)"]
[[package]]
name = "pycodestyle"
-version = "2.7.0"
+version = "2.8.0"
description = "Python style guide checker"
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycparser"
-version = "2.20"
+version = "2.21"
description = "C parser in Python"
category = "main"
optional = false
@@ -766,7 +757,7 @@ toml = ["toml"]
[[package]]
name = "pyflakes"
-version = "2.3.1"
+version = "2.4.0"
description = "passive checker of Python programs"
category = "dev"
optional = false
@@ -774,15 +765,18 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pyparsing"
-version = "2.4.7"
-description = "Python parsing module"
+version = "3.0.9"
+description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "main"
optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+python-versions = ">=3.6.8"
+
+[package.extras]
+diagrams = ["railroad-diagrams", "jinja2"]
[[package]]
name = "pyreadline3"
-version = "3.3"
+version = "3.4.1"
description = "A python implementation of GNU readline."
category = "main"
optional = false
@@ -790,11 +784,11 @@ python-versions = "*"
[[package]]
name = "pytest"
-version = "6.2.5"
+version = "7.1.1"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
[package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
@@ -804,34 +798,33 @@ iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
py = ">=1.8.2"
-toml = "*"
+tomli = ">=1.0.0"
[package.extras]
-testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "pytest-cov"
-version = "2.12.1"
+version = "3.0.0"
description = "Pytest plugin for measuring coverage."
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.6"
[package.dependencies]
-coverage = ">=5.2.1"
+coverage = {version = ">=5.2.1", extras = ["toml"]}
pytest = ">=4.6"
-toml = "*"
[package.extras]
testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-forked"
-version = "1.3.0"
+version = "1.4.0"
description = "run tests in isolated forked subprocesses"
category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.6"
[package.dependencies]
py = "*"
@@ -839,7 +832,7 @@ pytest = ">=3.10"
[[package]]
name = "pytest-xdist"
-version = "2.3.0"
+version = "2.5.0"
description = "pytest xdist plugin for distributed testing and loop-on-failing modes"
category = "dev"
optional = false
@@ -847,12 +840,12 @@ python-versions = ">=3.6"
[package.dependencies]
execnet = ">=1.1"
-psutil = {version = ">=3.0", optional = true, markers = "extra == \"psutil\""}
-pytest = ">=6.0.0"
+pytest = ">=6.2.0"
pytest-forked = "*"
[package.extras]
psutil = ["psutil (>=3.0)"]
+setproctitle = ["setproctitle"]
testing = ["filelock"]
[[package]]
@@ -868,11 +861,11 @@ six = ">=1.5"
[[package]]
name = "python-dotenv"
-version = "0.17.1"
+version = "0.20.0"
description = "Read key-value pairs from a .env file and set them as environment variables"
category = "dev"
optional = false
-python-versions = "*"
+python-versions = ">=3.5"
[package.extras]
cli = ["click (>=5.0)"]
@@ -894,47 +887,56 @@ test = ["pytest", "toml", "pyaml"]
[[package]]
name = "pyyaml"
-version = "5.4.1"
+version = "6.0"
description = "YAML parser and emitter for Python"
category = "main"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+python-versions = ">=3.6"
[[package]]
name = "rapidfuzz"
-version = "1.8.0"
+version = "2.0.7"
description = "rapid fuzzy string matching"
category = "main"
optional = false
-python-versions = ">=2.7"
+python-versions = ">=3.6"
+
+[package.dependencies]
+jarowinkler = ">=1.0.2,<1.1.0"
[package.extras]
full = ["numpy"]
[[package]]
name = "redis"
-version = "3.5.3"
-description = "Python client for Redis key-value store"
+version = "4.3.1"
+description = "Python client for Redis database and key-value store"
category = "main"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.6"
+
+[package.dependencies]
+async-timeout = ">=4.0.2"
+deprecated = ">=1.2.3"
+packaging = ">=20.4"
[package.extras]
-hiredis = ["hiredis (>=0.1.3)"]
+hiredis = ["hiredis (>=1.0.0)"]
+ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"]
[[package]]
name = "regex"
-version = "2021.4.4"
+version = "2022.3.15"
description = "Alternative regular expression module, to replace re."
category = "main"
optional = false
-python-versions = "*"
+python-versions = ">=3.6"
[[package]]
name = "requests"
-version = "2.26.0"
+version = "2.27.1"
description = "Python HTTP for Humans."
-category = "dev"
+category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
@@ -949,8 +951,20 @@ socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]]
+name = "requests-file"
+version = "1.5.1"
+description = "File transport adapter for Requests"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+requests = ">=1.0.0"
+six = "*"
+
+[[package]]
name = "sentry-sdk"
-version = "1.4.3"
+version = "1.5.8"
description = "Python client for Sentry (https://sentry.io)"
category = "main"
optional = false
@@ -972,6 +986,7 @@ flask = ["flask (>=0.11)", "blinker (>=1.1)"]
httpx = ["httpx (>=0.16.0)"]
pure_eval = ["pure-eval", "executing", "asttokens"]
pyspark = ["pyspark (>=2.4.4)"]
+quart = ["quart (>=0.16.1)", "blinker (>=1.1)"]
rq = ["rq (>=0.6)"]
sanic = ["sanic (>=0.8)"]
sqlalchemy = ["sqlalchemy (>=1.2)"]
@@ -995,7 +1010,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "snowballstemmer"
-version = "2.1.0"
+version = "2.2.0"
description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
category = "dev"
optional = false
@@ -1011,7 +1026,7 @@ python-versions = "*"
[[package]]
name = "soupsieve"
-version = "2.2.1"
+version = "2.3.2.post1"
description = "A modern CSS selector implementation for Beautiful Soup."
category = "main"
optional = false
@@ -1027,20 +1042,21 @@ python-versions = "*"
[[package]]
name = "taskipy"
-version = "1.7.0"
+version = "1.10.1"
description = "tasks runner for python projects"
category = "dev"
optional = false
python-versions = ">=3.6,<4.0"
[package.dependencies]
-mslex = ">=0.3.0,<0.4.0"
+colorama = ">=0.4.4,<0.5.0"
+mslex = {version = ">=0.3.0,<0.4.0", markers = "sys_platform == \"win32\""}
psutil = ">=5.7.2,<6.0.0"
-toml = ">=0.10.0,<0.11.0"
+tomli = ">=1.2.3,<2.0.0"
[[package]]
name = "testfixtures"
-version = "6.18.3"
+version = "6.18.5"
description = "A collection of helpers and mock objects for unit tests and doc tests."
category = "dev"
optional = false
@@ -1052,6 +1068,20 @@ docs = ["sphinx", "zope.component", "sybil", "twisted", "mock", "django (<2)", "
test = ["pytest (>=3.6)", "pytest-cov", "pytest-django", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"]
[[package]]
+name = "tldextract"
+version = "3.2.0"
+description = "Accurately separate the TLD from the registered domain and subdomains of a URL, using the Public Suffix List. By default, this includes the public ICANN TLDs and their exceptions. You can optionally support the Public Suffix List's private domains as well."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+filelock = ">=3.0.8"
+idna = "*"
+requests = ">=2.1.0"
+requests-file = ">=1.4"
+
+[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
@@ -1060,48 +1090,55 @@ optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
-name = "typing-extensions"
-version = "3.10.0.2"
-description = "Backported and Experimental Type Hints for Python 3.5+"
-category = "main"
+name = "tomli"
+version = "1.2.3"
+description = "A lil' TOML parser"
+category = "dev"
optional = false
-python-versions = "*"
+python-versions = ">=3.6"
[[package]]
name = "urllib3"
-version = "1.26.7"
+version = "1.26.9"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras]
-brotli = ["brotlipy (>=0.6.0)"]
+brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "virtualenv"
-version = "20.8.1"
+version = "20.14.1"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[package.dependencies]
-"backports.entry-points-selectable" = ">=1.0.4"
distlib = ">=0.3.1,<1"
-filelock = ">=3.0.0,<4"
+filelock = ">=3.2,<4"
platformdirs = ">=2,<3"
six = ">=1.9.0,<2"
[package.extras]
-docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
+docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"]
testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"]
[[package]]
+name = "wrapt"
+version = "1.14.1"
+description = "Module for decorators, wrappers and monkey patching."
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[[package]]
name = "yarl"
-version = "1.7.0"
+version = "1.7.2"
description = "Yet another URL library"
category = "main"
optional = false
@@ -1114,95 +1151,123 @@ multidict = ">=4.0"
[metadata]
lock-version = "1.1"
python-versions = "3.9.*"
-content-hash = "da321f13297501e62dd1eb362eccb586ea1a9c21ddb395e11a91b93a2f92e9d4"
+content-hash = "953529931f133865df736f9a6f96f59c64336963ef9e6ce6c959e6bd8c73792c"
[metadata.files]
-aio-pika = [
- {file = "aio-pika-6.8.0.tar.gz", hash = "sha256:1d4305a5f78af3857310b4fe48348cdcf6c097e0e275ea88c2cd08570531a369"},
- {file = "aio_pika-6.8.0-py3-none-any.whl", hash = "sha256:e69afef8695f47c5d107bbdba21bdb845d5c249acb3be53ef5c2d497b02657c0"},
-]
aiodns = [
- {file = "aiodns-2.0.0-py2.py3-none-any.whl", hash = "sha256:aaa5ac584f40fe778013df0aa6544bf157799bd3f608364b451840ed2c8688de"},
- {file = "aiodns-2.0.0.tar.gz", hash = "sha256:815fdef4607474295d68da46978a54481dd1e7be153c7d60f9e72773cd38d77d"},
+ {file = "aiodns-3.0.0-py3-none-any.whl", hash = "sha256:2b19bc5f97e5c936638d28e665923c093d8af2bf3aa88d35c43417fa25d136a2"},
+ {file = "aiodns-3.0.0.tar.gz", hash = "sha256:946bdfabe743fceeeb093c8a010f5d1645f708a241be849e17edfb0e49e08cd6"},
]
aiohttp = [
- {file = "aiohttp-3.7.4.post0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5"},
- {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8"},
- {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95"},
- {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290"},
- {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f"},
- {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809"},
- {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe"},
- {file = "aiohttp-3.7.4.post0-cp36-cp36m-win32.whl", hash = "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287"},
- {file = "aiohttp-3.7.4.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc"},
- {file = "aiohttp-3.7.4.post0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87"},
- {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0"},
- {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970"},
- {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f"},
- {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde"},
- {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c"},
- {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8"},
- {file = "aiohttp-3.7.4.post0-cp37-cp37m-win32.whl", hash = "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f"},
- {file = "aiohttp-3.7.4.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5"},
- {file = "aiohttp-3.7.4.post0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf"},
- {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df"},
- {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213"},
- {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4"},
- {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009"},
- {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5"},
- {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013"},
- {file = "aiohttp-3.7.4.post0-cp38-cp38-win32.whl", hash = "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16"},
- {file = "aiohttp-3.7.4.post0-cp38-cp38-win_amd64.whl", hash = "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5"},
- {file = "aiohttp-3.7.4.post0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b"},
- {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd"},
- {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439"},
- {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22"},
- {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a"},
- {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb"},
- {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb"},
- {file = "aiohttp-3.7.4.post0-cp39-cp39-win32.whl", hash = "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9"},
- {file = "aiohttp-3.7.4.post0-cp39-cp39-win_amd64.whl", hash = "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe"},
- {file = "aiohttp-3.7.4.post0.tar.gz", hash = "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf"},
+ {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"},
+ {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"},
+ {file = "aiohttp-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316"},
+ {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15"},
+ {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923"},
+ {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922"},
+ {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1"},
+ {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516"},
+ {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642"},
+ {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7"},
+ {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8"},
+ {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3"},
+ {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2"},
+ {file = "aiohttp-3.8.1-cp310-cp310-win32.whl", hash = "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa"},
+ {file = "aiohttp-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-win32.whl", hash = "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602"},
+ {file = "aiohttp-3.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-win32.whl", hash = "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9"},
+ {file = "aiohttp-3.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17"},
+ {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785"},
+ {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b"},
+ {file = "aiohttp-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd"},
+ {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e"},
+ {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd"},
+ {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700"},
+ {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675"},
+ {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf"},
+ {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0"},
+ {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5"},
+ {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950"},
+ {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155"},
+ {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33"},
+ {file = "aiohttp-3.8.1-cp38-cp38-win32.whl", hash = "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a"},
+ {file = "aiohttp-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75"},
+ {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237"},
+ {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74"},
+ {file = "aiohttp-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca"},
+ {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2"},
+ {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2"},
+ {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421"},
+ {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf"},
+ {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd"},
+ {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d"},
+ {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724"},
+ {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef"},
+ {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866"},
+ {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2"},
+ {file = "aiohttp-3.8.1-cp39-cp39-win32.whl", hash = "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1"},
+ {file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"},
+ {file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"},
]
aioredis = [
{file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"},
{file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"},
]
-aiormq = [
- {file = "aiormq-3.3.1-py3-none-any.whl", hash = "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e"},
- {file = "aiormq-3.3.1.tar.gz", hash = "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573"},
+aiosignal = [
+ {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"},
+ {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"},
]
arrow = [
- {file = "arrow-1.0.3-py3-none-any.whl", hash = "sha256:3515630f11a15c61dcb4cdd245883270dd334c83f3e639824e65a4b79cc48543"},
- {file = "arrow-1.0.3.tar.gz", hash = "sha256:399c9c8ae732270e1aa58ead835a79a40d7be8aa109c579898eb41029b5a231d"},
+ {file = "arrow-1.2.2-py3-none-any.whl", hash = "sha256:d622c46ca681b5b3e3574fcb60a04e5cc81b9625112d5fb2b44220c36c892177"},
+ {file = "arrow-1.2.2.tar.gz", hash = "sha256:05caf1fd3d9a11a1135b2b6f09887421153b94558e5ef4d090b567b47173ac2b"},
]
async-rediscache = [
- {file = "async-rediscache-0.1.4.tar.gz", hash = "sha256:6be8a657d724ccbcfb1946d29a80c3478c5f9ecd2f78a0a26d2f4013a622258f"},
- {file = "async_rediscache-0.1.4-py3-none-any.whl", hash = "sha256:c25e4fff73f64d20645254783c3224a4c49e083e3fab67c44f17af944c5e26af"},
+ {file = "async-rediscache-0.2.0.tar.gz", hash = "sha256:c1fd95fe530211b999748ebff96e2e9b629f2664957f9b36916b898e42fc57c4"},
+ {file = "async_rediscache-0.2.0-py3-none-any.whl", hash = "sha256:710676211b407399c9ad94afa66fa04c22a936be11ba6f227e6c74cfa140ce78"},
]
async-timeout = [
- {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"},
- {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"},
+ {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
+ {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"},
]
atomicwrites = [
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
]
attrs = [
- {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
- {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
-]
-"backports.entry-points-selectable" = [
- {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"},
- {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"},
+ {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
+ {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
]
beautifulsoup4 = [
{file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"},
{file = "beautifulsoup4-4.10.0.tar.gz", hash = "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"},
]
+bot-core = []
certifi = [
- {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
- {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
+ {file = "certifi-2022.5.18.1-py3-none-any.whl", hash = "sha256:f1d53542ee8cbedbe2118b5686372fb33c297fcd6379b050cca0ef13a597382a"},
+ {file = "certifi-2022.5.18.1.tar.gz", hash = "sha256:9c5705e395cd70084351dd8ad5c41e65655e08ce46f2ec9cf6c2c08390f71eb7"},
]
cffi = [
{file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"},
@@ -1260,122 +1325,104 @@ cfgv = [
{file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
{file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
]
-chardet = [
- {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
- {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
-]
charset-normalizer = [
- {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"},
- {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"},
+ {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
+ {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
]
coloredlogs = [
- {file = "coloredlogs-14.3-py2.py3-none-any.whl", hash = "sha256:e244a892f9d97ffd2c60f15bf1d2582ef7f9ac0f848d132249004184785702b3"},
- {file = "coloredlogs-14.3.tar.gz", hash = "sha256:7ef1a7219870c7f02c218a2f2877ce68f2f8e087bb3a55bd6fbaa2a4362b4d52"},
+ {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"},
+ {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"},
]
coverage = [
- {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"},
- {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"},
- {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"},
- {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"},
- {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"},
- {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"},
- {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"},
- {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"},
- {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"},
- {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"},
- {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"},
- {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"},
- {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"},
- {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"},
- {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"},
- {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"},
- {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"},
- {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"},
- {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"},
- {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"},
- {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"},
- {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"},
- {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"},
- {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"},
- {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"},
- {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"},
- {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"},
- {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"},
- {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"},
- {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"},
- {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"},
- {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"},
- {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"},
- {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"},
- {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"},
- {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"},
- {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"},
- {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"},
- {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"},
- {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"},
- {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"},
- {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"},
- {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"},
- {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"},
- {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"},
- {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"},
- {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"},
- {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"},
- {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"},
- {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"},
- {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"},
- {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"},
-]
-coveralls = [
- {file = "coveralls-2.2.0-py2.py3-none-any.whl", hash = "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc"},
- {file = "coveralls-2.2.0.tar.gz", hash = "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"},
+ {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"},
+ {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"},
+ {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"},
+ {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"},
+ {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"},
+ {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"},
+ {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"},
+ {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"},
+ {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"},
+ {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"},
+ {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"},
+ {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"},
+ {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"},
+ {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"},
+ {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"},
+ {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"},
+ {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"},
+ {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"},
+ {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"},
+ {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"},
+ {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"},
+ {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"},
+ {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"},
+ {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"},
+ {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"},
+ {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"},
+ {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"},
+ {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"},
+ {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"},
+ {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"},
+ {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"},
+ {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"},
+ {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"},
+ {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"},
+ {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"},
+ {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"},
+ {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"},
+ {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"},
+ {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"},
+ {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"},
+ {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"},
]
deepdiff = [
- {file = "deepdiff-4.3.2-py3-none-any.whl", hash = "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4"},
- {file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"},
+ {file = "deepdiff-5.7.0-py3-none-any.whl", hash = "sha256:1ffb38c3b5d9174eb2df95850c93aee55ec00e19396925036a2e680f725079e0"},
+ {file = "deepdiff-5.7.0.tar.gz", hash = "sha256:838766484e323dcd9dec6955926a893a83767dc3f3f94542773e6aa096efe5d4"},
+]
+deprecated = [
+ {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"},
+ {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"},
]
"discord.py" = []
distlib = [
- {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"},
- {file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"},
-]
-docopt = [
- {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
+ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"},
+ {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"},
]
emoji = [
- {file = "emoji-0.6.0.tar.gz", hash = "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"},
+ {file = "emoji-1.7.0.tar.gz", hash = "sha256:65c54533ea3c78f30d0729288998715f418d7467de89ec258a31c0ce8660a1d1"},
]
execnet = [
{file = "execnet-1.9.0-py2.py3-none-any.whl", hash = "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142"},
{file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"},
]
fakeredis = [
- {file = "fakeredis-1.6.1-py3-none-any.whl", hash = "sha256:5eb1516f1fe1813e9da8f6c482178fc067af09f53de587ae03887ef5d9d13024"},
- {file = "fakeredis-1.6.1.tar.gz", hash = "sha256:0d06a9384fb79da9f2164ce96e34eb9d4e2ea46215070805ea6fd3c174590b47"},
+ {file = "fakeredis-1.7.5-py3-none-any.whl", hash = "sha256:c4ca2be686e7e7637756ccc7dcad8472a5e4866b065431107d7a4b7a250d4e6f"},
+ {file = "fakeredis-1.7.5.tar.gz", hash = "sha256:49375c630981dd4045d9a92e2709fcd4476c91f927e0228493eefa625e705133"},
]
feedparser = [
{file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"},
{file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"},
]
filelock = [
- {file = "filelock-3.3.1-py3-none-any.whl", hash = "sha256:2b5eb3589e7fdda14599e7eb1a50e09b4cc14f34ed98b8ba56d33bfaafcbef2f"},
- {file = "filelock-3.3.1.tar.gz", hash = "sha256:34a9f35f95c441e7b38209775d6e0337f9a3759f3565f6c5798f19618527c76f"},
+ {file = "filelock-3.7.0-py3-none-any.whl", hash = "sha256:c7b5fdb219b398a5b28c8e4c1893ef5f98ece6a38c6ab2c22e26ec161556fed6"},
+ {file = "filelock-3.7.0.tar.gz", hash = "sha256:b795f1b42a61bbf8ec7113c341dad679d772567b936fbd1bf43c9a238e673e20"},
]
flake8 = [
- {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},
- {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"},
+ {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
+ {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
]
flake8-annotations = [
- {file = "flake8-annotations-2.7.0.tar.gz", hash = "sha256:52e53c05b0c06cac1c2dec192ea2c36e85081238add3bd99421d56f574b9479b"},
- {file = "flake8_annotations-2.7.0-py3-none-any.whl", hash = "sha256:3edfbbfb58e404868834fe6ec3eaf49c139f64f0701259f707d043185545151e"},
+ {file = "flake8-annotations-2.8.0.tar.gz", hash = "sha256:a2765c6043098aab0a3f519b871b33586c7fba7037686404b920cf8100cc1cdc"},
+ {file = "flake8_annotations-2.8.0-py3-none-any.whl", hash = "sha256:880f9bb0677b82655f9021112d64513e03caefd2e0d786ab4a59ddb5b262caa9"},
]
flake8-bugbear = [
- {file = "flake8-bugbear-20.11.1.tar.gz", hash = "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538"},
- {file = "flake8_bugbear-20.11.1-py36.py37.py38-none-any.whl", hash = "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"},
+ {file = "flake8-bugbear-22.3.23.tar.gz", hash = "sha256:e0dc2a36474490d5b1a2d57f9e4ef570abc09f07cbb712b29802e28a2367ff19"},
+ {file = "flake8_bugbear-22.3.23-py3-none-any.whl", hash = "sha256:ec5ec92195720cee1589315416b844ffa5e82f73a78e65329e8055322df1e939"},
]
flake8-docstrings = [
{file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"},
@@ -1394,12 +1441,73 @@ flake8-string-format = [
{file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"},
]
flake8-tidy-imports = [
- {file = "flake8-tidy-imports-4.5.0.tar.gz", hash = "sha256:ac637961d0f319012d099e49619f8c928e3221f74e00fe6eb89513bc64c40adb"},
- {file = "flake8_tidy_imports-4.5.0-py3-none-any.whl", hash = "sha256:87eed94ae6a2fda6a5918d109746feadf1311e0eb8274ab7a7920f6db00a41c9"},
+ {file = "flake8-tidy-imports-4.6.0.tar.gz", hash = "sha256:3e193d8c4bb4492408a90e956d888b27eed14c698387c9b38230da3dad78058f"},
+ {file = "flake8_tidy_imports-4.6.0-py3-none-any.whl", hash = "sha256:6ae9f55d628156e19d19f4c359dd5d3e95431a9bd514f5e2748c53c1398c66b2"},
]
flake8-todo = [
{file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"},
]
+frozenlist = [
+ {file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2257aaba9660f78c7b1d8fea963b68f3feffb1a9d5d05a18401ca9eb3e8d0a3"},
+ {file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4a44ebbf601d7bac77976d429e9bdb5a4614f9f4027777f9e54fd765196e9d3b"},
+ {file = "frozenlist-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:45334234ec30fc4ea677f43171b18a27505bfb2dba9aca4398a62692c0ea8868"},
+ {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47be22dc27ed933d55ee55845d34a3e4e9f6fee93039e7f8ebadb0c2f60d403f"},
+ {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03a7dd1bfce30216a3f51a84e6dd0e4a573d23ca50f0346634916ff105ba6e6b"},
+ {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:691ddf6dc50480ce49f68441f1d16a4c3325887453837036e0fb94736eae1e58"},
+ {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bde99812f237f79eaf3f04ebffd74f6718bbd216101b35ac7955c2d47c17da02"},
+ {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a202458d1298ced3768f5a7d44301e7c86defac162ace0ab7434c2e961166e8"},
+ {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9e3e9e365991f8cc5f5edc1fd65b58b41d0514a6a7ad95ef5c7f34eb49b3d3e"},
+ {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:04cb491c4b1c051734d41ea2552fde292f5f3a9c911363f74f39c23659c4af78"},
+ {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:436496321dad302b8b27ca955364a439ed1f0999311c393dccb243e451ff66aa"},
+ {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:754728d65f1acc61e0f4df784456106e35afb7bf39cfe37227ab00436fb38676"},
+ {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6eb275c6385dd72594758cbe96c07cdb9bd6becf84235f4a594bdf21e3596c9d"},
+ {file = "frozenlist-1.3.0-cp310-cp310-win32.whl", hash = "sha256:e30b2f9683812eb30cf3f0a8e9f79f8d590a7999f731cf39f9105a7c4a39489d"},
+ {file = "frozenlist-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f7353ba3367473d1d616ee727945f439e027f0bb16ac1a750219a8344d1d5d3c"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88aafd445a233dbbf8a65a62bc3249a0acd0d81ab18f6feb461cc5a938610d24"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4406cfabef8f07b3b3af0f50f70938ec06d9f0fc26cbdeaab431cbc3ca3caeaa"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cf829bd2e2956066dd4de43fd8ec881d87842a06708c035b37ef632930505a2"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:603b9091bd70fae7be28bdb8aa5c9990f4241aa33abb673390a7f7329296695f"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25af28b560e0c76fa41f550eacb389905633e7ac02d6eb3c09017fa1c8cdfde1"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c7a8a9fc9383b52c410a2ec952521906d355d18fccc927fca52ab575ee8b93"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:65bc6e2fece04e2145ab6e3c47428d1bbc05aede61ae365b2c1bddd94906e478"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3f7c935c7b58b0d78c0beea0c7358e165f95f1fd8a7e98baa40d22a05b4a8141"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd89acd1b8bb4f31b47072615d72e7f53a948d302b7c1d1455e42622de180eae"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:6983a31698490825171be44ffbafeaa930ddf590d3f051e397143a5045513b01"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:adac9700675cf99e3615eb6a0eb5e9f5a4143c7d42c05cea2e7f71c27a3d0846"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-win32.whl", hash = "sha256:0c36e78b9509e97042ef869c0e1e6ef6429e55817c12d78245eb915e1cca7468"},
+ {file = "frozenlist-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:57f4d3f03a18facacb2a6bcd21bccd011e3b75d463dc49f838fd699d074fabd1"},
+ {file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8c905a5186d77111f02144fab5b849ab524f1e876a1e75205cd1386a9be4b00a"},
+ {file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5009062d78a8c6890d50b4e53b0ddda31841b3935c1937e2ed8c1bda1c7fb9d"},
+ {file = "frozenlist-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2fdc3cd845e5a1f71a0c3518528bfdbfe2efaf9886d6f49eacc5ee4fd9a10953"},
+ {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e650bd09b5dda929523b9f8e7f99b24deac61240ecc1a32aeba487afcd970f"},
+ {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40dff8962b8eba91fd3848d857203f0bd704b5f1fa2b3fc9af64901a190bba08"},
+ {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:768efd082074bb203c934e83a61654ed4931ef02412c2fbdecea0cff7ecd0274"},
+ {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:006d3595e7d4108a12025ddf415ae0f6c9e736e726a5db0183326fd191b14c5e"},
+ {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:871d42623ae15eb0b0e9df65baeee6976b2e161d0ba93155411d58ff27483ad8"},
+ {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aff388be97ef2677ae185e72dc500d19ecaf31b698986800d3fc4f399a5e30a5"},
+ {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9f892d6a94ec5c7b785e548e42722e6f3a52f5f32a8461e82ac3e67a3bd073f1"},
+ {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e982878792c971cbd60ee510c4ee5bf089a8246226dea1f2138aa0bb67aff148"},
+ {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c6c321dd013e8fc20735b92cb4892c115f5cdb82c817b1e5b07f6b95d952b2f0"},
+ {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:30530930410855c451bea83f7b272fb1c495ed9d5cc72895ac29e91279401db3"},
+ {file = "frozenlist-1.3.0-cp38-cp38-win32.whl", hash = "sha256:40ec383bc194accba825fbb7d0ef3dda5736ceab2375462f1d8672d9f6b68d07"},
+ {file = "frozenlist-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:f20baa05eaa2bcd5404c445ec51aed1c268d62600362dc6cfe04fae34a424bd9"},
+ {file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0437fe763fb5d4adad1756050cbf855bbb2bf0d9385c7bb13d7a10b0dd550486"},
+ {file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b684c68077b84522b5c7eafc1dc735bfa5b341fb011d5552ebe0968e22ed641c"},
+ {file = "frozenlist-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93641a51f89473837333b2f8100f3f89795295b858cd4c7d4a1f18e299dc0a4f"},
+ {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6d32ff213aef0fd0bcf803bffe15cfa2d4fde237d1d4838e62aec242a8362fa"},
+ {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31977f84828b5bb856ca1eb07bf7e3a34f33a5cddce981d880240ba06639b94d"},
+ {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c62964192a1c0c30b49f403495911298810bada64e4f03249ca35a33ca0417a"},
+ {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4eda49bea3602812518765810af732229b4291d2695ed24a0a20e098c45a707b"},
+ {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb267b09a509c1df5a4ca04140da96016f40d2ed183cdc356d237286c971b51"},
+ {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e1e26ac0a253a2907d654a37e390904426d5ae5483150ce3adedb35c8c06614a"},
+ {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f96293d6f982c58ebebb428c50163d010c2f05de0cde99fd681bfdc18d4b2dc2"},
+ {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e84cb61b0ac40a0c3e0e8b79c575161c5300d1d89e13c0e02f76193982f066ed"},
+ {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:ff9310f05b9d9c5c4dd472983dc956901ee6cb2c3ec1ab116ecdde25f3ce4951"},
+ {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d26b650b71fdc88065b7a21f8ace70175bcf3b5bdba5ea22df4bfd893e795a3b"},
+ {file = "frozenlist-1.3.0-cp39-cp39-win32.whl", hash = "sha256:01a73627448b1f2145bddb6e6c2259988bb8aee0fb361776ff8604b99616cd08"},
+ {file = "frozenlist-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:772965f773757a6026dea111a15e6e2678fbd6216180f82a48a40b27de1ee2ab"},
+ {file = "frozenlist-1.3.0.tar.gz", hash = "sha256:ce6f2ba0edb7b0c1d8976565298ad2deba6f8064d2bebb6ffce2ca896eb35b0b"},
+]
hiredis = [
{file = "hiredis-2.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048"},
{file = "hiredis-2.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26"},
@@ -1448,8 +1556,8 @@ humanfriendly = [
{file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},
]
identify = [
- {file = "identify-2.3.0-py2.py3-none-any.whl", hash = "sha256:d1e82c83d063571bb88087676f81261a4eae913c492dafde184067c584bc7c05"},
- {file = "identify-2.3.0.tar.gz", hash = "sha256:fd08c97f23ceee72784081f1ce5125c8f53a02d3f2716dde79a6ab8f1039fea5"},
+ {file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"},
+ {file = "identify-2.5.1.tar.gz", hash = "sha256:3d11b16f3fe19f52039fb7e39c9c884b21cb1b586988114fbe42671f03de3e82"},
]
idna = [
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
@@ -1460,58 +1568,221 @@ iniconfig = [
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
isort = [
- {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"},
- {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"},
+ {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
+ {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
+]
+jarowinkler = [
+ {file = "jarowinkler-1.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:71772fcd787e0286b779de0f1bef1e0a25deb4578328c0fc633bc345f13ffd20"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:912ee0a465822a8d659413cebc1ab9937ac5850c9cd1e80be478ba209e7c8095"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0320f7187dced1ad413bf2c3631ec47567e65dfdea92c523aafb2c085ae15035"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58bc6a8f01b0dfdf3721f9a4954060addeccf8bbe5e72a71cf23a88ce0d30440"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:679ec7a42f70baa61f3a214d1b59cec90fc036021c759722075efcc8697e7b1f"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dde57d47962d6a4436d8a3b477bcc8233c6da28e675027eb3a490b0d6dc325be"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:657f50204970fac8f120c293e52a3451b742c9b26125010405ec7365cb6e2a49"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:04f18a7398766b36ffbe4bcd26d34fcd6ed01f4f2f7eea13e316e6cca0e10c98"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:33a24b380e2c076eabf2d3e12eee56b6bf10b1f326444e18c36a495387dbf0de"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e1d7d6e6c98fb785026584373240cc4076ad21033f508973faae05e846206e8c"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e50c750a45c800d91134200d8cbf746258ed357a663e97cc0348ee42a948386a"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:5b380afce6cdc25a4dafd86874f07a393800577c05335c6ad67ccda41db95c60"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e73712747ac5d2218af3ed3c1600377f18a0a45af95f22c39576165aea2908b4"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-win32.whl", hash = "sha256:9511f4e1f00c822e08dbffeb69e15c75eb294a5f24729815a97807ecf03d22eb"},
+ {file = "jarowinkler-1.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a5c44f92e9ac6088286292ecb69e970adc2b98e139b8923bce9bbb9d484e6a0f"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:02b0bf34ffc2995b695d9b10d2f18c1c447fbbdb7c913a84a0a48c186ccca3b8"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df7a8e45176298a1210c06f8b2328030cc3c93a45dab068ac1fbc9cf075cd95b"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da27a9c206249a50701bfa5cfbbb3a04236e1145b2b0967e825438acb14269bf"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43ea0155379df92021af0f4a32253be3953dfa0f050ec3515f314b8f48a96674"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f33b6b1687db1be1abba60850628ee71547501592fcf3504e021274bc5ccb7a"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff304de32ee6acd5387103a0ad584060d8d419aa19cbbeca95204de9c4f01171"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:662dd6f59cca536640be0cda32c901989504d95316b192e6aa41d098fa08c795"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:01f85abb75fa43e98db34853d35570d98495ee2fcbbf45a93838e0289c162f19"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:5b9332dcc8130af4101c9752a03e977c54b8c12982a2a3ca4c2e4cc542accc00"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:af765b037404a536c372e33ddd4c430aea28f1d82a8ef51a2955442b8b690577"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aea2c7d66b57c56d00f9c45ae7862d86e3ae84368ecea17f3552c0052a7f3bcf"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-win32.whl", hash = "sha256:8b1288a09a8d100e9bf7cf9ce1329433db73a0d0350d74c2c6f5c31ac69096cf"},
+ {file = "jarowinkler-1.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ed39199b0e806902347473c65e5c05933549cf7e55ba628c6812782f2c310b19"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:473b057d7e5a0f5e5b8c0e0f7960d3ca2f2954c3c93fd7a9fb2cc4bc3cc940fb"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdb892dbbbd77b3789a10b2ce5e8acfe5821cc6423e835bae2b489159f3c2211"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:012a8333328ce061cba1ff081843c8d80eb1afe8fa2889ad29d767ea3fdc7562"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3421120c07ee6d3f59c5adde32eb9a050cfd1b3666b0e2d8c337d934a9d091f9"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dad57327cc90f8daa3afb98e2d274d7dd1b60651f32717449be95d3b3366d61a"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4fd1757eff43df97227fd63d9c8078582267a0b25cefef6f6a64d3e46e80ba2"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:32269ebbcb860f01c055d9bb145b4cc91990f62c7644a85b21458b4868621113"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3b5a0839e84f5ff914b01b5b94d0273954affce9cc2b2ee2c31fe2fcb9c8ae76"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:6c9d3a9ef008428b5dce2855eebe2b6127ea7a7e433aedf240653fad4bd4baa6"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:a3d7759d8a66ee05595bde012f93da8a63499f38205e2bb47022c52bd6c47108"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2ba1b1b0bf45042a9bbb95d272fd8b0c559fe8f6806f088ec0372899e1bc6224"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-win32.whl", hash = "sha256:4cb33f4343774d69abf8cf65ad57919e7a171c44ba6ad57b08147c3f0f06b073"},
+ {file = "jarowinkler-1.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:0392b72ddb5ab5d6c1d5df94dbdac7bf229670e5e64b2b9a382d02d6158755e5"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:94f663ad85bc7a89d7e8b6048f93a46d2848a0570ab07fc895a239b9a5d97b93"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:895a10766ff3db15e7cf2b735e4277bee051eaafb437aaaef2c5de64a5c3f05c"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0c1a84e770b3ec7385a4f40efb30bdc96f96844564f91f8d3937d54a8969d82c"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27defe81d76e02b3929322baea999f5232837e7f308c2dc5b37de7568c2bc583"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:158f117481388f8d23fe4bd2567f37be0ccae0f4631c34e4b0345803147da207"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:427c675b4f3e83c79a4b6af7441f29e30a173c7a0ae72a54f51090eee7a8ae02"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90a7f3fd173339bc62e52c02f43d50c947cb3af9cda41646e218aea13547e0c2"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3975cbe8b6ae13fc63d74bcbed8dac1577078d8cd8728e60621fe75885d2a8c5"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:141840f33345b00abd611839080edc99d4d31abd2dcf701a3e50c90f9bfb2383"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f592f9f6179e347a5f518ca7feb9bf3ac068f2fad60ece5a0eef5e5e580d4c8b"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:30565d70396eb9d1eb622e1e707ddc2f3b7a9692558b8bf4ea49415a5ca2f854"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:35fc430c11b80a43ed826879c78c4197ec665d5150745b3668bec961acf8a757"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e4cf4b7090f0c4075bec1638717f54b22c3b0fe733dc87146a19574346ed3161"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-win32.whl", hash = "sha256:199f4f7edbc49439a97440caa1e244d2e33da3e16d7b0afce4e4dfd307e555c7"},
+ {file = "jarowinkler-1.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:b587e8fdd96cc470d6bdf428129c65264731b09b5db442e2d092e983feec4aab"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4b233180b3e2f2d7967aa570d36984e9d2ec5a9067c0d1c44cd3b805d9da9363"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2220665a1f52262ae8b76e3baf474ebcd209bfcb6a7cada346ffd62818f5aa3e"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08c98387e04e749c84cc967db628e5047843f19f87bf515a35b72f7050bc28ad"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d710921657442ad3c942de684aba0bdf16b7de5feed3223b12f3b2517cf17f7c"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:401c02ac7245103826f54c816324274f53d50b638ab0f8b359a13055a7a6e793"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a1929a0029f208cc9244499dc93b4d52ee8e80d2849177d425cf6e0be1ea781"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab25d147be9b04e7de2d28a18e72fadc152698c3e51683c6c61f73ffbae2f9e"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:465cfdff355ec9c55f65fd1e1315260ec20c8cff0eb90d9f1a0ad8d503dc002b"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:29ef1113697cc74c2f04bc15008abbd726cb2d5b01c040ba87c6cb7abd1d0e0d"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:61b57c8b36361ec889f99f761441bb0fa21b850a5eb3305dea25fef68f6a797b"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ee9d9af1bbf194d78f4b69c2139807c23451068b27a053a1400d683d6f36c61d"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a9b33b0ceb472bbc65683467189bd032c162256b2a137586ee3448a9f8f886ec"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:582f6e213a6744883ced44482a51efcc21ae632defac27f12f6430a8e99b1070"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-win32.whl", hash = "sha256:4d1c8f403016d5c0262de7a8588eee370c37a609e1f529f8407e99a70d020af7"},
+ {file = "jarowinkler-1.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:ab50ffa66aa201616871c1b90ac0790f56666118db3c8a8fcb3a7a6e03971510"},
+ {file = "jarowinkler-1.0.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8e59a289dcf93504ab92795666c39b2dbe98ac18655201992a7e6247de676bf4"},
+ {file = "jarowinkler-1.0.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c36eccdc866f06a7b35da701bd8f91e0dfc83b35c07aba75ce8c906cbafaf184"},
+ {file = "jarowinkler-1.0.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123163f01a5c43f12e4294e7ce567607d859e1446b1a43bd6cd404b3403ffa07"},
+ {file = "jarowinkler-1.0.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d41fdecd907189e47c7d478e558ad417da38bf3eb34cc20527035cb3fca3e2b8"},
+ {file = "jarowinkler-1.0.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e7829368fc91de225f37f6325f8d8ec7ad831dc5b0e9547f1977e2fdc85eccc1"},
+ {file = "jarowinkler-1.0.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:278595417974553a8fdf3c8cce5c2b4f859335344075b870ecb55cc416eb76cf"},
+ {file = "jarowinkler-1.0.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:208fc49741db5d3e6bbd4a2f7b32d32644b462bf205e7510eca4e2d530225f03"},
+ {file = "jarowinkler-1.0.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:924afcab6739c453f1c3492701d185d71dc0e5ba15692bd0bfa6d482c7e8f79e"},
+ {file = "jarowinkler-1.0.2.tar.gz", hash = "sha256:788ac33e6ffdbd78fd913b481e37cfa149288575f087a1aae1a4ce219cb1c654"},
+]
+lupa = [
+ {file = "lupa-1.13-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:da1885faca29091f9e408c0cc6b43a0b29a2128acf8d08c188febc5d9f99129d"},
+ {file = "lupa-1.13-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4525e954e951562eb5609eca6ac694d0158a5351649656e50d524f87f71e2a35"},
+ {file = "lupa-1.13-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5a04febcd3016cb992e6c5b2f97834ad53a2fd4b37767d9afdce116021c2463a"},
+ {file = "lupa-1.13-cp27-cp27m-win32.whl", hash = "sha256:98f6d3debc4d3668e5e19d70e288dbdbbedef021a75ac2e42c450c7679b4bf52"},
+ {file = "lupa-1.13-cp27-cp27m-win_amd64.whl", hash = "sha256:7009719bf65549c018a2f925ff06b9d862a5a1e22f8a7aeeef807eb1e99b56bc"},
+ {file = "lupa-1.13-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bde9e73b06d147d31b970123a013cc6d28a4bea7b3d6b64fe115650cbc62b1a3"},
+ {file = "lupa-1.13-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a122baad6c6f9aaae496a59318217c068ae73654f618526e404a28775b46da38"},
+ {file = "lupa-1.13-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:4d1588486ed16d6b53f41b080047d44db3aa9991cf8a30da844cb97486a63c8b"},
+ {file = "lupa-1.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:a79be3ca652c8392d612bdc2234074325a68ec572c4175a35347cd650ef4a4b9"},
+ {file = "lupa-1.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d9105f3b098cd4c276d6258f8254224243066f51c5d3c923b8f460efac9de37b"},
+ {file = "lupa-1.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2d1fbddfa2914c405004f805afb13f5fc385793f3ba28e86a6f0c85b4059b86c"},
+ {file = "lupa-1.13-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5a3c84994399887a8befc82aef4d837582db45a301413025c510e20fef9e9148"},
+ {file = "lupa-1.13-cp310-cp310-win32.whl", hash = "sha256:c665af2a92e79106045f973174e0849f92b44395f5247505d321bc1173d9f3fd"},
+ {file = "lupa-1.13-cp310-cp310-win_amd64.whl", hash = "sha256:c9b47a9e93cb8e8f342343f4e0963eb1966d36baeced482575141925eafc17dc"},
+ {file = "lupa-1.13-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:b3003d723faabb9502259662722462cbff368f26ed83a6311f65949d298593bf"},
+ {file = "lupa-1.13-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b341b8a4711558af771bd4a954a6ffe531bfe097c1f1cdce84b9ad56070dfe90"},
+ {file = "lupa-1.13-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ea049ee507a549eec553a9d27e3e6c034eae8c145e7bad5947e85c4b9e23757b"},
+ {file = "lupa-1.13-cp35-cp35m-win32.whl", hash = "sha256:ba6c49646ad42c836f18ff8f1b6b8db4ca32fc02e786e1bf401b0fa34fe82cca"},
+ {file = "lupa-1.13-cp35-cp35m-win_amd64.whl", hash = "sha256:de51177d1374fd9cce27b9cdb20771142d91a509e42337b3e7c6cffbba818d6f"},
+ {file = "lupa-1.13-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:dddfeb031ab67c8bdbeefd2de237a98bee58e2166d5ed629c3a0c3842bb91738"},
+ {file = "lupa-1.13-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57f00004c185bd60459586a9d08961541f5da1cfec5925a3fc1ab68deaa2e038"},
+ {file = "lupa-1.13-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a940be5b38b68b344691558ffde1b44377ad66c105661f6f58c7d4c0c227d8ea"},
+ {file = "lupa-1.13-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:807b27c13f7598af9343455204a6a23b6b919180f01668c9b8fa4f9b0d75dedb"},
+ {file = "lupa-1.13-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a52d5a8305f4854f91ee39f5ee6f175f4d38f362c6b00483fe618ae6f9dff5b"},
+ {file = "lupa-1.13-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0ad47549359df03b3e59796ba09df548e1fd046f9245391dae79699c9ffec0f6"},
+ {file = "lupa-1.13-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:fbf99cea003b38a146dff5333ba58edb8165e01c42f15d7f76fdb72e761b5827"},
+ {file = "lupa-1.13-cp36-cp36m-win32.whl", hash = "sha256:a101c84097fdfa7b1a38f9d5a3055759da4e222c255ab8e5ac5b683704e62c97"},
+ {file = "lupa-1.13-cp36-cp36m-win_amd64.whl", hash = "sha256:00376b3bcb00bb57e067740ea9ff00f610a44aff5338ea93d3198a035f8965c6"},
+ {file = "lupa-1.13-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:91001c9667d60b69c3ad623dc315d7b59712e1617fe6204e5852c31cda778678"},
+ {file = "lupa-1.13-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:65c9d034d7215e8929a4ab48c9d9d372786ef47c8e61c294851bf0b8f5b4fbf4"},
+ {file = "lupa-1.13-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:928527222b2a15bd3dcea646f7585852097302c078c338fb0f184ce560d48c6c"},
+ {file = "lupa-1.13-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:5e157d97e379931a7fa90d9afa66600f796960bc062e04a9bb37f24fa7c5c967"},
+ {file = "lupa-1.13-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a67336d542d71e095c07dacc72c16158745ae4ef08e8a7bfe75827da604b4979"},
+ {file = "lupa-1.13-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0c5cd027c998db5b29ca8dd956c255d50914aed614d1c9edb68bc3315f916f59"},
+ {file = "lupa-1.13-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:76b06355f0b3d3aece5c38d20a66ab7d3046add95b8d04b677ade162fce2ffd0"},
+ {file = "lupa-1.13-cp37-cp37m-win32.whl", hash = "sha256:2a6b0a7e45390de36d11dd8705b2a0a10739ba8ed2e99c130e983ad72d56ddc9"},
+ {file = "lupa-1.13-cp37-cp37m-win_amd64.whl", hash = "sha256:42ffbe43119225cc58c7ebd2210123b9367b098ac25a7f0ef5d473e2f65fc0d9"},
+ {file = "lupa-1.13-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:7ff445a5d8ab25e623f871c600af58f1cd6207f6873a42c3b8c1683f13a22db0"},
+ {file = "lupa-1.13-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:dd0404f11b9473372fe2a8bdf0d64b361852ae08699d6dcde1215db3bd6c7b9c"},
+ {file = "lupa-1.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:14419b29152667fb2d78c6d5176f9a704c765aeecb80fe6c079a8dba9f864529"},
+ {file = "lupa-1.13-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:9e644032b40b59420ffa0d58ca1705351785ce8e39b77d9f1a8c4cf78e371adb"},
+ {file = "lupa-1.13-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c090991e2b701ded6c9e330ea582a74dd9cb09069b3de9ae897b938bd97dc98f"},
+ {file = "lupa-1.13-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6812f16530a1dc88f66c76a002e1c16039d3d98e1ff283a2efd5a492342ba00c"},
+ {file = "lupa-1.13-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ff3989ab562fb62e9df2290739c7f82e05d5ba7d2fa2ea319991885dfc818c81"},
+ {file = "lupa-1.13-cp38-cp38-win32.whl", hash = "sha256:48fa15cf24d297c50f21bff1fe1883f7a6a15b34b70db5a6c18d2dfbed6b6e16"},
+ {file = "lupa-1.13-cp38-cp38-win_amd64.whl", hash = "sha256:ea32a62d404c3d9e119e83b653aa56c034cae63a4e830aefa15bf3a25299b29e"},
+ {file = "lupa-1.13-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:80d36fbdc6218332232b4c214a2f9c36b13136b546dca0b3d19aca12d77e1f8e"},
+ {file = "lupa-1.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:db4745132f8abe0c9daac155af9d196926c9e10662d999edd805756d91502a01"},
+ {file = "lupa-1.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:938fb12c556737f9e4ffb7912540e35423d1be3166c6d4099ca4f3e177fe619e"},
+ {file = "lupa-1.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:de913a471ee6dc86435b647dda3cdb787990b164d8c8c63ca03d6e934f305a55"},
+ {file = "lupa-1.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:488d1bd773f10331ca67b0914c880900316634fd14538f76c3c2fbc7e6b56043"},
+ {file = "lupa-1.13-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:dc101e6d82ffa1b3fcfc77f2430a10c02def972cf0f8c7a229e272697e22e35c"},
+ {file = "lupa-1.13-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:361a55883b692d25478a69104d8ecce4cad058ba39ec1b7378b1209f86867687"},
+ {file = "lupa-1.13-cp39-cp39-win32.whl", hash = "sha256:9a6cd192e789fbc7f6a777a17b5b517c447a6dc6049e60c1becb300f86205345"},
+ {file = "lupa-1.13-cp39-cp39-win_amd64.whl", hash = "sha256:9fe47cda7cc81bd9b111f1317ed60e3da2620f4fef5360b690dcf62f88bbc668"},
+ {file = "lupa-1.13-pp37-pypy37_pp73-macosx_10_14_x86_64.whl", hash = "sha256:7d860dc0062b3001993355b12b939f68e0e2871a19a81427d2a9ced893574b58"},
+ {file = "lupa-1.13-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6c0358386f16afb50145b143774791c942c93a9721078a17983486a2d9f8f45b"},
+ {file = "lupa-1.13-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:a46962ebdc6278e82520c66d5dd1eed50099aa2f56b6827b7a4f001664d9ad1d"},
+ {file = "lupa-1.13-pp37-pypy37_pp73-win32.whl", hash = "sha256:436daf32385bcb9b6b9f922cbc0b64d133db141f0f7d8946a3a653e83b478713"},
+ {file = "lupa-1.13-pp38-pypy38_pp73-macosx_10_14_x86_64.whl", hash = "sha256:f1165e89aa8d2a0644619517e04410b9f5e3da2c9b3d105bf53f70e786f91f79"},
+ {file = "lupa-1.13-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:325069e4f3cf4b1232d03fb330ba1449867fc7dd727ecebaf0e602ddcacaf9d4"},
+ {file = "lupa-1.13-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:ce59c335b80ec4f9e98181970c18552f51adba5c3380ef5d46bdb3246b87963d"},
+ {file = "lupa-1.13-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:ad263ba6e54a13ac036364ae43ba7613c869c5ee6ff7dbb86791685a6cba13c5"},
+ {file = "lupa-1.13-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:86f4f46ee854e36cf5b6cf2317075023f395eede53efec0a694bc4a01fc03ab7"},
+ {file = "lupa-1.13-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:59799f40774dd5b8cfb99b11d6ce3a3f3a141e112472874389d47c81a7377ef9"},
+ {file = "lupa-1.13.tar.gz", hash = "sha256:e1d94ac2a630d271027dac2c21d1428771d9ea9d4d88f15f20a7781340f02a4e"},
]
lxml = [
- {file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"},
- {file = "lxml-4.6.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f"},
- {file = "lxml-4.6.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d"},
- {file = "lxml-4.6.3-cp27-cp27m-win32.whl", hash = "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106"},
- {file = "lxml-4.6.3-cp27-cp27m-win_amd64.whl", hash = "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee"},
- {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f"},
- {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4"},
- {file = "lxml-4.6.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:64812391546a18896adaa86c77c59a4998f33c24788cadc35789e55b727a37f4"},
- {file = "lxml-4.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c1a40c06fd5ba37ad39caa0b3144eb3772e813b5fb5b084198a985431c2f1e8d"},
- {file = "lxml-4.6.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51"},
- {file = "lxml-4.6.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"},
- {file = "lxml-4.6.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354"},
- {file = "lxml-4.6.3-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16"},
- {file = "lxml-4.6.3-cp35-cp35m-win32.whl", hash = "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2"},
- {file = "lxml-4.6.3-cp35-cp35m-win_amd64.whl", hash = "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4"},
- {file = "lxml-4.6.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4"},
- {file = "lxml-4.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3"},
- {file = "lxml-4.6.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d"},
- {file = "lxml-4.6.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24"},
- {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec"},
- {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617"},
- {file = "lxml-4.6.3-cp36-cp36m-win32.whl", hash = "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04"},
- {file = "lxml-4.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a"},
- {file = "lxml-4.6.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654"},
- {file = "lxml-4.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0"},
- {file = "lxml-4.6.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3"},
- {file = "lxml-4.6.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96"},
- {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2"},
- {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92"},
- {file = "lxml-4.6.3-cp37-cp37m-win32.whl", hash = "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade"},
- {file = "lxml-4.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b"},
- {file = "lxml-4.6.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa"},
- {file = "lxml-4.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a"},
- {file = "lxml-4.6.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927"},
- {file = "lxml-4.6.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e"},
- {file = "lxml-4.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791"},
- {file = "lxml-4.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae"},
- {file = "lxml-4.6.3-cp38-cp38-win32.whl", hash = "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28"},
- {file = "lxml-4.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7"},
- {file = "lxml-4.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0"},
- {file = "lxml-4.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1"},
- {file = "lxml-4.6.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23"},
- {file = "lxml-4.6.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59"},
- {file = "lxml-4.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969"},
- {file = "lxml-4.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a"},
- {file = "lxml-4.6.3-cp39-cp39-win32.whl", hash = "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f"},
- {file = "lxml-4.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83"},
- {file = "lxml-4.6.3.tar.gz", hash = "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468"},
+ {file = "lxml-4.8.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:e1ab2fac607842ac36864e358c42feb0960ae62c34aa4caaf12ada0a1fb5d99b"},
+ {file = "lxml-4.8.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28d1af847786f68bec57961f31221125c29d6f52d9187c01cd34dc14e2b29430"},
+ {file = "lxml-4.8.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b92d40121dcbd74831b690a75533da703750f7041b4bf951befc657c37e5695a"},
+ {file = "lxml-4.8.0-cp27-cp27m-win32.whl", hash = "sha256:e01f9531ba5420838c801c21c1b0f45dbc9607cb22ea2cf132844453bec863a5"},
+ {file = "lxml-4.8.0-cp27-cp27m-win_amd64.whl", hash = "sha256:6259b511b0f2527e6d55ad87acc1c07b3cbffc3d5e050d7e7bcfa151b8202df9"},
+ {file = "lxml-4.8.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1010042bfcac2b2dc6098260a2ed022968dbdfaf285fc65a3acf8e4eb1ffd1bc"},
+ {file = "lxml-4.8.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fa56bb08b3dd8eac3a8c5b7d075c94e74f755fd9d8a04543ae8d37b1612dd170"},
+ {file = "lxml-4.8.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:31ba2cbc64516dcdd6c24418daa7abff989ddf3ba6d3ea6f6ce6f2ed6e754ec9"},
+ {file = "lxml-4.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:31499847fc5f73ee17dbe1b8e24c6dafc4e8d5b48803d17d22988976b0171f03"},
+ {file = "lxml-4.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5f7d7d9afc7b293147e2d506a4596641d60181a35279ef3aa5778d0d9d9123fe"},
+ {file = "lxml-4.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a3c5f1a719aa11866ffc530d54ad965063a8cbbecae6515acbd5f0fae8f48eaa"},
+ {file = "lxml-4.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6268e27873a3d191849204d00d03f65c0e343b3bcb518a6eaae05677c95621d1"},
+ {file = "lxml-4.8.0-cp310-cp310-win32.whl", hash = "sha256:330bff92c26d4aee79c5bc4d9967858bdbe73fdbdbacb5daf623a03a914fe05b"},
+ {file = "lxml-4.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b2582b238e1658c4061ebe1b4df53c435190d22457642377fd0cb30685cdfb76"},
+ {file = "lxml-4.8.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a2bfc7e2a0601b475477c954bf167dee6d0f55cb167e3f3e7cefad906e7759f6"},
+ {file = "lxml-4.8.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a1547ff4b8a833511eeaceacbcd17b043214fcdb385148f9c1bc5556ca9623e2"},
+ {file = "lxml-4.8.0-cp35-cp35m-win32.whl", hash = "sha256:a9f1c3489736ff8e1c7652e9dc39f80cff820f23624f23d9eab6e122ac99b150"},
+ {file = "lxml-4.8.0-cp35-cp35m-win_amd64.whl", hash = "sha256:530f278849031b0eb12f46cca0e5db01cfe5177ab13bd6878c6e739319bae654"},
+ {file = "lxml-4.8.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:078306d19a33920004addeb5f4630781aaeabb6a8d01398045fcde085091a169"},
+ {file = "lxml-4.8.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:86545e351e879d0b72b620db6a3b96346921fa87b3d366d6c074e5a9a0b8dadb"},
+ {file = "lxml-4.8.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24f5c5ae618395ed871b3d8ebfcbb36e3f1091fd847bf54c4de623f9107942f3"},
+ {file = "lxml-4.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bbab6faf6568484707acc052f4dfc3802bdb0cafe079383fbaa23f1cdae9ecd4"},
+ {file = "lxml-4.8.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7993232bd4044392c47779a3c7e8889fea6883be46281d45a81451acfd704d7e"},
+ {file = "lxml-4.8.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6d6483b1229470e1d8835e52e0ff3c6973b9b97b24cd1c116dca90b57a2cc613"},
+ {file = "lxml-4.8.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:ad4332a532e2d5acb231a2e5d33f943750091ee435daffca3fec0a53224e7e33"},
+ {file = "lxml-4.8.0-cp36-cp36m-win32.whl", hash = "sha256:db3535733f59e5605a88a706824dfcb9bd06725e709ecb017e165fc1d6e7d429"},
+ {file = "lxml-4.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5f148b0c6133fb928503cfcdfdba395010f997aa44bcf6474fcdd0c5398d9b63"},
+ {file = "lxml-4.8.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:8a31f24e2a0b6317f33aafbb2f0895c0bce772980ae60c2c640d82caac49628a"},
+ {file = "lxml-4.8.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:719544565c2937c21a6f76d520e6e52b726d132815adb3447ccffbe9f44203c4"},
+ {file = "lxml-4.8.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:c0b88ed1ae66777a798dc54f627e32d3b81c8009967c63993c450ee4cbcbec15"},
+ {file = "lxml-4.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fa9b7c450be85bfc6cd39f6df8c5b8cbd76b5d6fc1f69efec80203f9894b885f"},
+ {file = "lxml-4.8.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e9f84ed9f4d50b74fbc77298ee5c870f67cb7e91dcdc1a6915cb1ff6a317476c"},
+ {file = "lxml-4.8.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1d650812b52d98679ed6c6b3b55cbb8fe5a5460a0aef29aeb08dc0b44577df85"},
+ {file = "lxml-4.8.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:80bbaddf2baab7e6de4bc47405e34948e694a9efe0861c61cdc23aa774fcb141"},
+ {file = "lxml-4.8.0-cp37-cp37m-win32.whl", hash = "sha256:6f7b82934c08e28a2d537d870293236b1000d94d0b4583825ab9649aef7ddf63"},
+ {file = "lxml-4.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e1fd7d2fe11f1cb63d3336d147c852f6d07de0d0020d704c6031b46a30b02ca8"},
+ {file = "lxml-4.8.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5045ee1ccd45a89c4daec1160217d363fcd23811e26734688007c26f28c9e9e7"},
+ {file = "lxml-4.8.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0c1978ff1fd81ed9dcbba4f91cf09faf1f8082c9d72eb122e92294716c605428"},
+ {file = "lxml-4.8.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cbf2ff155b19dc4d4100f7442f6a697938bf4493f8d3b0c51d45568d5666b5"},
+ {file = "lxml-4.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ce13d6291a5f47c1c8dbd375baa78551053bc6b5e5c0e9bb8e39c0a8359fd52f"},
+ {file = "lxml-4.8.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11527dc23d5ef44d76fef11213215c34f36af1608074561fcc561d983aeb870"},
+ {file = "lxml-4.8.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:60d2f60bd5a2a979df28ab309352cdcf8181bda0cca4529769a945f09aba06f9"},
+ {file = "lxml-4.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:62f93eac69ec0f4be98d1b96f4d6b964855b8255c345c17ff12c20b93f247b68"},
+ {file = "lxml-4.8.0-cp38-cp38-win32.whl", hash = "sha256:20b8a746a026017acf07da39fdb10aa80ad9877046c9182442bf80c84a1c4696"},
+ {file = "lxml-4.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:891dc8f522d7059ff0024cd3ae79fd224752676447f9c678f2a5c14b84d9a939"},
+ {file = "lxml-4.8.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:b6fc2e2fb6f532cf48b5fed57567ef286addcef38c28874458a41b7837a57807"},
+ {file = "lxml-4.8.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:74eb65ec61e3c7c019d7169387d1b6ffcfea1b9ec5894d116a9a903636e4a0b1"},
+ {file = "lxml-4.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:627e79894770783c129cc5e89b947e52aa26e8e0557c7e205368a809da4b7939"},
+ {file = "lxml-4.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:545bd39c9481f2e3f2727c78c169425efbfb3fbba6e7db4f46a80ebb249819ca"},
+ {file = "lxml-4.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5a58d0b12f5053e270510bf12f753a76aaf3d74c453c00942ed7d2c804ca845c"},
+ {file = "lxml-4.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ec4b4e75fc68da9dc0ed73dcdb431c25c57775383fec325d23a770a64e7ebc87"},
+ {file = "lxml-4.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5804e04feb4e61babf3911c2a974a5b86f66ee227cc5006230b00ac6d285b3a9"},
+ {file = "lxml-4.8.0-cp39-cp39-win32.whl", hash = "sha256:aa0cf4922da7a3c905d000b35065df6184c0dc1d866dd3b86fd961905bbad2ea"},
+ {file = "lxml-4.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:dd10383f1d6b7edf247d0960a3db274c07e96cf3a3fc7c41c8448f93eac3fb1c"},
+ {file = "lxml-4.8.0-pp37-pypy37_pp73-macosx_10_14_x86_64.whl", hash = "sha256:2403a6d6fb61c285969b71f4a3527873fe93fd0abe0832d858a17fe68c8fa507"},
+ {file = "lxml-4.8.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:986b7a96228c9b4942ec420eff37556c5777bfba6758edcb95421e4a614b57f9"},
+ {file = "lxml-4.8.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6fe4ef4402df0250b75ba876c3795510d782def5c1e63890bde02d622570d39e"},
+ {file = "lxml-4.8.0-pp38-pypy38_pp73-macosx_10_14_x86_64.whl", hash = "sha256:f10ce66fcdeb3543df51d423ede7e238be98412232fca5daec3e54bcd16b8da0"},
+ {file = "lxml-4.8.0-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:730766072fd5dcb219dd2b95c4c49752a54f00157f322bc6d71f7d2a31fecd79"},
+ {file = "lxml-4.8.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8b99ec73073b37f9ebe8caf399001848fced9c08064effdbfc4da2b5a8d07b93"},
+ {file = "lxml-4.8.0.tar.gz", hash = "sha256:f63f62fc60e6228a4ca9abae28228f35e1bd3ce675013d1dfb828688d50c6e23"},
]
markdownify = [
{file = "markdownify-0.6.1-py3-none-any.whl", hash = "sha256:7489fd5c601536996a376c4afbcd1dd034db7690af807120681461e82fbc0acc"},
@@ -1522,86 +1793,73 @@ mccabe = [
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
]
more-itertools = [
- {file = "more-itertools-8.10.0.tar.gz", hash = "sha256:1debcabeb1df793814859d64a81ad7cb10504c24349368ccf214c664c474f41f"},
- {file = "more_itertools-8.10.0-py3-none-any.whl", hash = "sha256:56ddac45541718ba332db05f464bebfb0768110111affd27f66e0051f276fa43"},
+ {file = "more-itertools-8.12.0.tar.gz", hash = "sha256:7dc6ad46f05f545f900dd59e8dfb4e84a4827b97b3cfecb175ea0c7d247f6064"},
+ {file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"},
]
mslex = [
{file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"},
{file = "mslex-0.3.0.tar.gz", hash = "sha256:4a1ac3f25025cad78ad2fe499dd16d42759f7a3801645399cce5c404415daa97"},
]
multidict = [
- {file = "multidict-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3822c5894c72e3b35aae9909bef66ec83e44522faf767c0ad39e0e2de11d3b55"},
- {file = "multidict-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:28e6d883acd8674887d7edc896b91751dc2d8e87fbdca8359591a13872799e4e"},
- {file = "multidict-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b61f85101ef08cbbc37846ac0e43f027f7844f3fade9b7f6dd087178caedeee7"},
- {file = "multidict-5.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9b668c065968c5979fe6b6fa6760bb6ab9aeb94b75b73c0a9c1acf6393ac3bf"},
- {file = "multidict-5.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:517d75522b7b18a3385726b54a081afd425d4f41144a5399e5abd97ccafdf36b"},
- {file = "multidict-5.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b4ac3ba7a97b35a5ccf34f41b5a8642a01d1e55454b699e5e8e7a99b5a3acf5"},
- {file = "multidict-5.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:df23c83398715b26ab09574217ca21e14694917a0c857e356fd39e1c64f8283f"},
- {file = "multidict-5.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e58a9b5cc96e014ddf93c2227cbdeca94b56a7eb77300205d6e4001805391747"},
- {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f76440e480c3b2ca7f843ff8a48dc82446b86ed4930552d736c0bac507498a52"},
- {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cfde464ca4af42a629648c0b0d79b8f295cf5b695412451716531d6916461628"},
- {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0fed465af2e0eb6357ba95795d003ac0bdb546305cc2366b1fc8f0ad67cc3fda"},
- {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:b70913cbf2e14275013be98a06ef4b412329fe7b4f83d64eb70dce8269ed1e1a"},
- {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a5635bcf1b75f0f6ef3c8a1ad07b500104a971e38d3683167b9454cb6465ac86"},
- {file = "multidict-5.2.0-cp310-cp310-win32.whl", hash = "sha256:77f0fb7200cc7dedda7a60912f2059086e29ff67cefbc58d2506638c1a9132d7"},
- {file = "multidict-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:9416cf11bcd73c861267e88aea71e9fcc35302b3943e45e1dbb4317f91a4b34f"},
- {file = "multidict-5.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:fd77c8f3cba815aa69cb97ee2b2ef385c7c12ada9c734b0f3b32e26bb88bbf1d"},
- {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ec9aea6223adf46999f22e2c0ab6cf33f5914be604a404f658386a8f1fba37"},
- {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5283c0a00f48e8cafcecadebfa0ed1dac8b39e295c7248c44c665c16dc1138b"},
- {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f79c19c6420962eb17c7e48878a03053b7ccd7b69f389d5831c0a4a7f1ac0a1"},
- {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e4a67f1080123de76e4e97a18d10350df6a7182e243312426d508712e99988d4"},
- {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:94b117e27efd8e08b4046c57461d5a114d26b40824995a2eb58372b94f9fca02"},
- {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2e77282fd1d677c313ffcaddfec236bf23f273c4fba7cdf198108f5940ae10f5"},
- {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:116347c63ba049c1ea56e157fa8aa6edaf5e92925c9b64f3da7769bdfa012858"},
- {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:dc3a866cf6c13d59a01878cd806f219340f3e82eed514485e094321f24900677"},
- {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac42181292099d91217a82e3fa3ce0e0ddf3a74fd891b7c2b347a7f5aa0edded"},
- {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:f0bb0973f42ffcb5e3537548e0767079420aefd94ba990b61cf7bb8d47f4916d"},
- {file = "multidict-5.2.0-cp36-cp36m-win32.whl", hash = "sha256:ea21d4d5104b4f840b91d9dc8cbc832aba9612121eaba503e54eaab1ad140eb9"},
- {file = "multidict-5.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:e6453f3cbeb78440747096f239d282cc57a2997a16b5197c9bc839099e1633d0"},
- {file = "multidict-5.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3def943bfd5f1c47d51fd324df1e806d8da1f8e105cc7f1c76a1daf0f7e17b0"},
- {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35591729668a303a02b06e8dba0eb8140c4a1bfd4c4b3209a436a02a5ac1de11"},
- {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8cacda0b679ebc25624d5de66c705bc53dcc7c6f02a7fb0f3ca5e227d80422"},
- {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:baf1856fab8212bf35230c019cde7c641887e3fc08cadd39d32a421a30151ea3"},
- {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a43616aec0f0d53c411582c451f5d3e1123a68cc7b3475d6f7d97a626f8ff90d"},
- {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:25cbd39a9029b409167aa0a20d8a17f502d43f2efebfe9e3ac019fe6796c59ac"},
- {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a2cbcfbea6dc776782a444db819c8b78afe4db597211298dd8b2222f73e9cd0"},
- {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3d2d7d1fff8e09d99354c04c3fd5b560fb04639fd45926b34e27cfdec678a704"},
- {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a37e9a68349f6abe24130846e2f1d2e38f7ddab30b81b754e5a1fde32f782b23"},
- {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:637c1896497ff19e1ee27c1c2c2ddaa9f2d134bbb5e0c52254361ea20486418d"},
- {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9815765f9dcda04921ba467957be543423e5ec6a1136135d84f2ae092c50d87b"},
- {file = "multidict-5.2.0-cp37-cp37m-win32.whl", hash = "sha256:8b911d74acdc1fe2941e59b4f1a278a330e9c34c6c8ca1ee21264c51ec9b67ef"},
- {file = "multidict-5.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:380b868f55f63d048a25931a1632818f90e4be71d2081c2338fcf656d299949a"},
- {file = "multidict-5.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e7d81ce5744757d2f05fc41896e3b2ae0458464b14b5a2c1e87a6a9d69aefaa8"},
- {file = "multidict-5.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d1d55cdf706ddc62822d394d1df53573d32a7a07d4f099470d3cb9323b721b6"},
- {file = "multidict-5.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a4771d0d0ac9d9fe9e24e33bed482a13dfc1256d008d101485fe460359476065"},
- {file = "multidict-5.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da7d57ea65744d249427793c042094c4016789eb2562576fb831870f9c878d9e"},
- {file = "multidict-5.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdd68778f96216596218b4e8882944d24a634d984ee1a5a049b300377878fa7c"},
- {file = "multidict-5.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecc99bce8ee42dcad15848c7885197d26841cb24fa2ee6e89d23b8993c871c64"},
- {file = "multidict-5.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:067150fad08e6f2dd91a650c7a49ba65085303fcc3decbd64a57dc13a2733031"},
- {file = "multidict-5.2.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:78c106b2b506b4d895ddc801ff509f941119394b89c9115580014127414e6c2d"},
- {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e6c4fa1ec16e01e292315ba76eb1d012c025b99d22896bd14a66628b245e3e01"},
- {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b227345e4186809d31f22087d0265655114af7cda442ecaf72246275865bebe4"},
- {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:06560fbdcf22c9387100979e65b26fba0816c162b888cb65b845d3def7a54c9b"},
- {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7878b61c867fb2df7a95e44b316f88d5a3742390c99dfba6c557a21b30180cac"},
- {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:246145bff76cc4b19310f0ad28bd0769b940c2a49fc601b86bfd150cbd72bb22"},
- {file = "multidict-5.2.0-cp38-cp38-win32.whl", hash = "sha256:c30ac9f562106cd9e8071c23949a067b10211917fdcb75b4718cf5775356a940"},
- {file = "multidict-5.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:f19001e790013ed580abfde2a4465388950728861b52f0da73e8e8a9418533c0"},
- {file = "multidict-5.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c1ff762e2ee126e6f1258650ac641e2b8e1f3d927a925aafcfde943b77a36d24"},
- {file = "multidict-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bd6c9c50bf2ad3f0448edaa1a3b55b2e6866ef8feca5d8dbec10ec7c94371d21"},
- {file = "multidict-5.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fc66d4016f6e50ed36fb39cd287a3878ffcebfa90008535c62e0e90a7ab713ae"},
- {file = "multidict-5.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9acb76d5f3dd9421874923da2ed1e76041cb51b9337fd7f507edde1d86535d6"},
- {file = "multidict-5.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dfc924a7e946dd3c6360e50e8f750d51e3ef5395c95dc054bc9eab0f70df4f9c"},
- {file = "multidict-5.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fdba7333eb2351fee2596b756d730d62b5827d5e1ab2f84e6cbb287cc67fe0"},
- {file = "multidict-5.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b9aad49466b8d828b96b9e3630006234879c8d3e2b0a9d99219b3121bc5cdb17"},
- {file = "multidict-5.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:93de39267c4c676c9ebb2057e98a8138bade0d806aad4d864322eee0803140a0"},
- {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f9bef5cff994ca3026fcc90680e326d1a19df9841c5e3d224076407cc21471a1"},
- {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5f841c4f14331fd1e36cbf3336ed7be2cb2a8f110ce40ea253e5573387db7621"},
- {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:38ba256ee9b310da6a1a0f013ef4e422fca30a685bcbec86a969bd520504e341"},
- {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3bc3b1621b979621cee9f7b09f024ec76ec03cc365e638126a056317470bde1b"},
- {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6ee908c070020d682e9b42c8f621e8bb10c767d04416e2ebe44e37d0f44d9ad5"},
- {file = "multidict-5.2.0-cp39-cp39-win32.whl", hash = "sha256:1c7976cd1c157fa7ba5456ae5d31ccdf1479680dc9b8d8aa28afabc370df42b8"},
- {file = "multidict-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:c9631c642e08b9fff1c6255487e62971d8b8e821808ddd013d8ac058087591ac"},
- {file = "multidict-5.2.0.tar.gz", hash = "sha256:0dd1c93edb444b33ba2274b66f63def8a327d607c6c790772f448a53b6ea59ce"},
+ {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"},
+ {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"},
+ {file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"},
+ {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"},
+ {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"},
+ {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"},
+ {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"},
+ {file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"},
+ {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"},
+ {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"},
+ {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"},
+ {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"},
+ {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"},
+ {file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"},
+ {file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"},
+ {file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"},
+ {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"},
+ {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"},
+ {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"},
+ {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"},
+ {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"},
+ {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"},
+ {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"},
+ {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"},
+ {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"},
+ {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"},
+ {file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"},
+ {file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"},
+ {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"},
+ {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"},
+ {file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"},
+ {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"},
+ {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"},
+ {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"},
+ {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"},
+ {file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"},
+ {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"},
+ {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"},
+ {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"},
+ {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"},
+ {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"},
+ {file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"},
+ {file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"},
+ {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"},
+ {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"},
+ {file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"},
+ {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"},
+ {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"},
+ {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"},
+ {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"},
+ {file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"},
+ {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"},
+ {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"},
+ {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"},
+ {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"},
+ {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"},
+ {file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"},
+ {file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"},
+ {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
]
nodeenv = [
{file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
@@ -1611,12 +1869,8 @@ ordered-set = [
{file = "ordered-set-4.0.2.tar.gz", hash = "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"},
]
packaging = [
- {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
- {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
-]
-pamqp = [
- {file = "pamqp-2.3.0-py2.py3-none-any.whl", hash = "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02"},
- {file = "pamqp-2.3.0.tar.gz", hash = "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"},
+ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
+ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
]
pep8-naming = [
{file = "pep8-naming-0.12.1.tar.gz", hash = "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"},
@@ -1627,53 +1881,57 @@ pip-licenses = [
{file = "pip_licenses-3.5.3-py3-none-any.whl", hash = "sha256:59c148d6a03784bf945d232c0dc0e9de4272a3675acaa0361ad7712398ca86ba"},
]
platformdirs = [
- {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"},
- {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"},
+ {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
+ {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
pre-commit = [
- {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"},
- {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"},
+ {file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"},
+ {file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"},
]
psutil = [
- {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"},
- {file = "psutil-5.8.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:0ae6f386d8d297177fd288be6e8d1afc05966878704dad9847719650e44fc49c"},
- {file = "psutil-5.8.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:12d844996d6c2b1d3881cfa6fa201fd635971869a9da945cf6756105af73d2df"},
- {file = "psutil-5.8.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:02b8292609b1f7fcb34173b25e48d0da8667bc85f81d7476584d889c6e0f2131"},
- {file = "psutil-5.8.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6ffe81843131ee0ffa02c317186ed1e759a145267d54fdef1bc4ea5f5931ab60"},
- {file = "psutil-5.8.0-cp27-none-win32.whl", hash = "sha256:ea313bb02e5e25224e518e4352af4bf5e062755160f77e4b1767dd5ccb65f876"},
- {file = "psutil-5.8.0-cp27-none-win_amd64.whl", hash = "sha256:5da29e394bdedd9144c7331192e20c1f79283fb03b06e6abd3a8ae45ffecee65"},
- {file = "psutil-5.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:74fb2557d1430fff18ff0d72613c5ca30c45cdbfcddd6a5773e9fc1fe9364be8"},
- {file = "psutil-5.8.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:74f2d0be88db96ada78756cb3a3e1b107ce8ab79f65aa885f76d7664e56928f6"},
- {file = "psutil-5.8.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99de3e8739258b3c3e8669cb9757c9a861b2a25ad0955f8e53ac662d66de61ac"},
- {file = "psutil-5.8.0-cp36-cp36m-win32.whl", hash = "sha256:36b3b6c9e2a34b7d7fbae330a85bf72c30b1c827a4366a07443fc4b6270449e2"},
- {file = "psutil-5.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:52de075468cd394ac98c66f9ca33b2f54ae1d9bff1ef6b67a212ee8f639ec06d"},
- {file = "psutil-5.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c6a5fd10ce6b6344e616cf01cc5b849fa8103fbb5ba507b6b2dee4c11e84c935"},
- {file = "psutil-5.8.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:61f05864b42fedc0771d6d8e49c35f07efd209ade09a5afe6a5059e7bb7bf83d"},
- {file = "psutil-5.8.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0dd4465a039d343925cdc29023bb6960ccf4e74a65ad53e768403746a9207023"},
- {file = "psutil-5.8.0-cp37-cp37m-win32.whl", hash = "sha256:1bff0d07e76114ec24ee32e7f7f8d0c4b0514b3fae93e3d2aaafd65d22502394"},
- {file = "psutil-5.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:fcc01e900c1d7bee2a37e5d6e4f9194760a93597c97fee89c4ae51701de03563"},
- {file = "psutil-5.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6223d07a1ae93f86451d0198a0c361032c4c93ebd4bf6d25e2fb3edfad9571ef"},
- {file = "psutil-5.8.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d225cd8319aa1d3c85bf195c4e07d17d3cd68636b8fc97e6cf198f782f99af28"},
- {file = "psutil-5.8.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:28ff7c95293ae74bf1ca1a79e8805fcde005c18a122ca983abf676ea3466362b"},
- {file = "psutil-5.8.0-cp38-cp38-win32.whl", hash = "sha256:ce8b867423291cb65cfc6d9c4955ee9bfc1e21fe03bb50e177f2b957f1c2469d"},
- {file = "psutil-5.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:90f31c34d25b1b3ed6c40cdd34ff122b1887a825297c017e4cbd6796dd8b672d"},
- {file = "psutil-5.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6323d5d845c2785efb20aded4726636546b26d3b577aded22492908f7c1bdda7"},
- {file = "psutil-5.8.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:245b5509968ac0bd179287d91210cd3f37add77dad385ef238b275bad35fa1c4"},
- {file = "psutil-5.8.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:90d4091c2d30ddd0a03e0b97e6a33a48628469b99585e2ad6bf21f17423b112b"},
- {file = "psutil-5.8.0-cp39-cp39-win32.whl", hash = "sha256:ea372bcc129394485824ae3e3ddabe67dc0b118d262c568b4d2602a7070afdb0"},
- {file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"},
- {file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"},
+ {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:799759d809c31aab5fe4579e50addf84565e71c1dc9f1c31258f159ff70d3f87"},
+ {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9272167b5f5fbfe16945be3db475b3ce8d792386907e673a209da686176552af"},
+ {file = "psutil-5.9.1-cp27-cp27m-win32.whl", hash = "sha256:0904727e0b0a038830b019551cf3204dd48ef5c6868adc776e06e93d615fc5fc"},
+ {file = "psutil-5.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e7e10454cb1ab62cc6ce776e1c135a64045a11ec4c6d254d3f7689c16eb3efd2"},
+ {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:56960b9e8edcca1456f8c86a196f0c3d8e3e361320071c93378d41445ffd28b0"},
+ {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:44d1826150d49ffd62035785a9e2c56afcea66e55b43b8b630d7706276e87f22"},
+ {file = "psutil-5.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7be9d7f5b0d206f0bbc3794b8e16fb7dbc53ec9e40bbe8787c6f2d38efcf6c9"},
+ {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd9246e4cdd5b554a2ddd97c157e292ac11ef3e7af25ac56b08b455c829dca8"},
+ {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29a442e25fab1f4d05e2655bb1b8ab6887981838d22effa2396d584b740194de"},
+ {file = "psutil-5.9.1-cp310-cp310-win32.whl", hash = "sha256:20b27771b077dcaa0de1de3ad52d22538fe101f9946d6dc7869e6f694f079329"},
+ {file = "psutil-5.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:58678bbadae12e0db55186dc58f2888839228ac9f41cc7848853539b70490021"},
+ {file = "psutil-5.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a76ad658641172d9c6e593de6fe248ddde825b5866464c3b2ee26c35da9d237"},
+ {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6a11e48cb93a5fa606306493f439b4aa7c56cb03fc9ace7f6bfa21aaf07c453"},
+ {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068935df39055bf27a29824b95c801c7a5130f118b806eee663cad28dca97685"},
+ {file = "psutil-5.9.1-cp36-cp36m-win32.whl", hash = "sha256:0f15a19a05f39a09327345bc279c1ba4a8cfb0172cc0d3c7f7d16c813b2e7d36"},
+ {file = "psutil-5.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:db417f0865f90bdc07fa30e1aadc69b6f4cad7f86324b02aa842034efe8d8c4d"},
+ {file = "psutil-5.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:91c7ff2a40c373d0cc9121d54bc5f31c4fa09c346528e6a08d1845bce5771ffc"},
+ {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fea896b54f3a4ae6f790ac1d017101252c93f6fe075d0e7571543510f11d2676"},
+ {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3054e923204b8e9c23a55b23b6df73a8089ae1d075cb0bf711d3e9da1724ded4"},
+ {file = "psutil-5.9.1-cp37-cp37m-win32.whl", hash = "sha256:d2d006286fbcb60f0b391741f520862e9b69f4019b4d738a2a45728c7e952f1b"},
+ {file = "psutil-5.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b14ee12da9338f5e5b3a3ef7ca58b3cba30f5b66f7662159762932e6d0b8f680"},
+ {file = "psutil-5.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:19f36c16012ba9cfc742604df189f2f28d2720e23ff7d1e81602dbe066be9fd1"},
+ {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:944c4b4b82dc4a1b805329c980f270f170fdc9945464223f2ec8e57563139cf4"},
+ {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b6750a73a9c4a4e689490ccb862d53c7b976a2a35c4e1846d049dcc3f17d83b"},
+ {file = "psutil-5.9.1-cp38-cp38-win32.whl", hash = "sha256:a8746bfe4e8f659528c5c7e9af5090c5a7d252f32b2e859c584ef7d8efb1e689"},
+ {file = "psutil-5.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:79c9108d9aa7fa6fba6e668b61b82facc067a6b81517cab34d07a84aa89f3df0"},
+ {file = "psutil-5.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28976df6c64ddd6320d281128817f32c29b539a52bdae5e192537bc338a9ec81"},
+ {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b88f75005586131276634027f4219d06e0561292be8bd6bc7f2f00bdabd63c4e"},
+ {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:645bd4f7bb5b8633803e0b6746ff1628724668681a434482546887d22c7a9537"},
+ {file = "psutil-5.9.1-cp39-cp39-win32.whl", hash = "sha256:32c52611756096ae91f5d1499fe6c53b86f4a9ada147ee42db4991ba1520e574"},
+ {file = "psutil-5.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:f65f9a46d984b8cd9b3750c2bdb419b2996895b005aefa6cbaba9a143b1ce2c5"},
+ {file = "psutil-5.9.1.tar.gz", hash = "sha256:57f1819b5d9e95cdfb0c881a8a5b7d542ed0b7c522d575706a80bedc848c8954"},
]
ptable = [
{file = "PTable-0.9.2.tar.gz", hash = "sha256:aa7fc151cb40f2dabcd2275ba6f7fd0ff8577a86be3365cd3fb297cbe09cc292"},
]
py = [
- {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
- {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
+ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
+ {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
]
pycares = [
{file = "pycares-4.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:71b99b9e041ae3356b859822c511f286f84c8889ec9ed1fbf6ac30fb4da13e4c"},
@@ -1709,202 +1967,228 @@ pycares = [
{file = "pycares-4.1.2.tar.gz", hash = "sha256:03490be0e7b51a0c8073f877bec347eff31003f64f57d9518d419d9369452837"},
]
pycodestyle = [
- {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
- {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
+ {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"},
+ {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"},
]
pycparser = [
- {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
- {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
+ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
+ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
pydocstyle = [
{file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"},
{file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"},
]
pyflakes = [
- {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
- {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
+ {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
+ {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"},
]
pyparsing = [
- {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
- {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
+ {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
+ {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
]
pyreadline3 = [
- {file = "pyreadline3-3.3-py3-none-any.whl", hash = "sha256:0003fd0079d152ecbd8111202c5a7dfa6a5569ffd65b235e45f3c2ecbee337b4"},
- {file = "pyreadline3-3.3.tar.gz", hash = "sha256:ff3b5a1ac0010d0967869f723e687d42cabc7dccf33b14934c92aa5168d260b3"},
+ {file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"},
+ {file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"},
]
pytest = [
- {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"},
- {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"},
+ {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"},
+ {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"},
]
pytest-cov = [
- {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"},
- {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"},
+ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
+ {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"},
]
pytest-forked = [
- {file = "pytest-forked-1.3.0.tar.gz", hash = "sha256:6aa9ac7e00ad1a539c41bec6d21011332de671e938c7637378ec9710204e37ca"},
- {file = "pytest_forked-1.3.0-py2.py3-none-any.whl", hash = "sha256:dc4147784048e70ef5d437951728825a131b81714b398d5d52f17c7c144d8815"},
+ {file = "pytest-forked-1.4.0.tar.gz", hash = "sha256:8b67587c8f98cbbadfdd804539ed5455b6ed03802203485dd2f53c1422d7440e"},
+ {file = "pytest_forked-1.4.0-py3-none-any.whl", hash = "sha256:bbbb6717efc886b9d64537b41fb1497cfaf3c9601276be8da2cccfea5a3c8ad8"},
]
pytest-xdist = [
- {file = "pytest-xdist-2.3.0.tar.gz", hash = "sha256:e8ecde2f85d88fbcadb7d28cb33da0fa29bca5cf7d5967fa89fc0e97e5299ea5"},
- {file = "pytest_xdist-2.3.0-py3-none-any.whl", hash = "sha256:ed3d7da961070fce2a01818b51f6888327fb88df4379edeb6b9d990e789d9c8d"},
+ {file = "pytest-xdist-2.5.0.tar.gz", hash = "sha256:4580deca3ff04ddb2ac53eba39d76cb5dd5edeac050cb6fbc768b0dd712b4edf"},
+ {file = "pytest_xdist-2.5.0-py3-none-any.whl", hash = "sha256:6fe5c74fec98906deb8f2d2b616b5c782022744978e7bd4695d39c8f42d0ce65"},
]
python-dateutil = [
{file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
{file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
]
python-dotenv = [
- {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"},
- {file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"},
+ {file = "python-dotenv-0.20.0.tar.gz", hash = "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f"},
+ {file = "python_dotenv-0.20.0-py3-none-any.whl", hash = "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938"},
]
python-frontmatter = [
{file = "python-frontmatter-1.0.0.tar.gz", hash = "sha256:e98152e977225ddafea6f01f40b4b0f1de175766322004c826ca99842d19a7cd"},
{file = "python_frontmatter-1.0.0-py3-none-any.whl", hash = "sha256:766ae75f1b301ffc5fe3494339147e0fd80bc3deff3d7590a93991978b579b08"},
]
pyyaml = [
- {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
- {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"},
- {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"},
- {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"},
- {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"},
- {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"},
- {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"},
- {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"},
- {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"},
- {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"},
- {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"},
- {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"},
- {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"},
- {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"},
- {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"},
- {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"},
- {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"},
- {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"},
- {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"},
- {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"},
- {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"},
- {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"},
- {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"},
- {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"},
- {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"},
- {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"},
- {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"},
- {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
- {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
+ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
+ {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
+ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
+ {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
+ {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
+ {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
+ {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
+ {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
+ {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
+ {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
+ {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
+ {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
+ {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
+ {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
+ {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
+ {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
+ {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
+ {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
+ {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
+ {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
+ {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
+ {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
+ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
]
rapidfuzz = [
- {file = "rapidfuzz-1.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:91f094562c683802e6c972bce27a692dad70d6cd1114e626b29d990c3704c653"},
- {file = "rapidfuzz-1.8.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:4a20682121e245cf5ad2dbdd771360763ea11b77520632a1034c4bb9ad1e854c"},
- {file = "rapidfuzz-1.8.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8810e75d8f9c4453bbd6209c372bf97514359b0b5efff555caf85b15f8a9d862"},
- {file = "rapidfuzz-1.8.0-cp27-cp27m-win32.whl", hash = "sha256:00cf713d843735b5958d87294f08b05c653a593ced7c4120be34f5d26d7a320a"},
- {file = "rapidfuzz-1.8.0-cp27-cp27m-win_amd64.whl", hash = "sha256:2baca64e23a623e077f57e5470de21af2765af15aa1088676eb2d475e664eed0"},
- {file = "rapidfuzz-1.8.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:9bf7a6c61bacedd84023be356e057e1d209dd6997cfaa3c1cee77aa21d642f88"},
- {file = "rapidfuzz-1.8.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:61b6434e3341ca5158ecb371b1ceb4c1f6110563a72d28bdce4eb2a084493e47"},
- {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e425e690383f6cf308e8c2e8d630fa9596f67d233344efd8fae11e70a9f5635f"},
- {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:93db5e693b76d616b09df27ca5c79e0dda169af7f1b8f5ab3262826d981e37e2"},
- {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a8c4f76ed1c8a65892d98dc2913027c9acdb219d18f3a441cfa427a32861af9"},
- {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71e217fd30901214cc96c0c15057278bafb7072aa9b2be4c97459c1fedf3e731"},
- {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d579dd447b8e851462e79054b68f94b66b09df8b3abb2aa5ca07fe00912ef5e8"},
- {file = "rapidfuzz-1.8.0-cp310-cp310-win32.whl", hash = "sha256:5808064555273496dcd594d659bd28ee8d399149dd31575321034424455dc955"},
- {file = "rapidfuzz-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:798fef1671ca66c78b47802228e9583f7ab32b99bdfe3984ebb1f96e93e38b5f"},
- {file = "rapidfuzz-1.8.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:c9e0ed210831f5c73533bf11099ea7897db491e76c3443bef281d9c1c67d7f3a"},
- {file = "rapidfuzz-1.8.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:c819bb19eb615a31ddc9cb8248a285bf04f58158b53ce096451178631f99b652"},
- {file = "rapidfuzz-1.8.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:942ee45564f28ef70320d1229f02dc998bd93e3519c1f3a80f33ce144b51039c"},
- {file = "rapidfuzz-1.8.0-cp35-cp35m-win32.whl", hash = "sha256:7e6ae2e5a3bc9acc51e118f25d32b8efcd431c5d8deb408336dd2ed0f21d087c"},
- {file = "rapidfuzz-1.8.0-cp35-cp35m-win_amd64.whl", hash = "sha256:98901fba67c89ad2506f3946642cf6eb8f489592fb7eb307ebdf8bdb0c4e97f9"},
- {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:705e1686f406a0c77ef323cdb7369b7cf9e68f2abfcb83ff5f1e0a5b21f5a534"},
- {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da0c5fe5fdbbd74206c1778af6b8c5ff8dfbe2dd04ae12bbe96642b358acefce"},
- {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:535253bc9224215131ae450aad6c9f7ef1b24f15c685045eab2b52511268bd06"},
- {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acdad83f07d886705fce164b0d1f4e3b56788a205602ed3a7fc8b10ceaf05fbf"},
- {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35097f649831f8375d6c65a237deccac3aceb573aa7fae1e5d3fa942e89de1c8"},
- {file = "rapidfuzz-1.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6f4db142e5b4b44314166a90e11603220db659bd2f9c23dd5db402c13eac8eb7"},
- {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:19a3f55f27411d68360540484874beda0b428b062596d5f0f141663ef0738bfd"},
- {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22b4c1a7f6fe29bd8dae49f7d5ab085dc42c3964f1a78b6dca22fdf83b5c9bfa"},
- {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8bfb2fbc147904b78d5c510ee75dc8704b606e956df23f33a9e89abc03f45c3"},
- {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6dc5111ebfed2c4f2e4d120a9b280ea13ea4fbb60b6915dd239817b4fc092ed"},
- {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db5ee2457d97cb967ffe08446a8c595c03fe747fdc2e145266713f9c516d1c4a"},
- {file = "rapidfuzz-1.8.0-cp37-cp37m-win32.whl", hash = "sha256:12c1b78cc15fc26f555a4bf66088d5afb6354b5a5aa149a123f01a15af6c411b"},
- {file = "rapidfuzz-1.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:693e9579048d8db4ff020715dd6f25aa315fd6445bc94e7400d7a94a227dad27"},
- {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b4fe19df3edcf7de359448b872aec08e6592b4ca2d3df4d8ee57b5812d68bebf"},
- {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f3670b9df0e1f479637cad1577afca7766a02775dc08c14837cf495c82861d7c"},
- {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61d118f36eb942649b0db344f7b7a19ad7e9b5749d831788187eb03b57ce1bfa"},
- {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fce3a2c8a1d10da12aff4a0d367624e8ae9e15c1b84a5144843681d39be0c355"},
- {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1577ef26e3647ccc4cc9754c34ffaa731639779f4d7779e91a761c72adac093e"},
- {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fec9b7e60fde51990c3b48fc1aa9dba9ac3acaf78f623dbb645a6fe21a9654e"},
- {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b954469d93858bc8b48129bc63fd644382a4df5f3fb1b4b290f48eac1d00a2da"},
- {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:190ba709069a7e5a6b39b7c8bc413a08cfa7f1f4defec5d974c4128b510e0234"},
- {file = "rapidfuzz-1.8.0-cp38-cp38-win32.whl", hash = "sha256:97b2d13d6323649b43d1b113681e4013ba230bd6e9827cc832dcebee447d7250"},
- {file = "rapidfuzz-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:81c3091209b75f6611efe2af18834180946d4ce28f41ca8d44fce816187840d2"},
- {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d610afa33e92aa0481a514ffda3ec51ca5df3c684c1c1c795307589c62025931"},
- {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d976f33ca6b5fabbb095c0a662f5b86baf706184fc24c7f125d4ddb54b8bf036"},
- {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0f5ca7bca2af598d4ddcf5b93b64b50654a9ff684e6f18d865f6e13fee442b3e"},
- {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2aac5ea6b0306dcd28a6d1a89d35ed2c6ac426f2673ee1b92cf3f1d0fd5cd"},
- {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f145c9831c0454a696a3136a6380ea4e01434e9cc2f2bc10d032864c16d1d0e5"},
- {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ce53291575b56c9d45add73ea013f43bafcea55eee9d5139aa759918d7685f"},
- {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de5773a39c00a0f23cfc5da9e0e5fd0fb512b0ebe23dc7289a38e1f9a4b5cefc"},
- {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87a802e55792bfbe192e2d557f38867dbe3671b49b3d5ecd873859c7460746ba"},
- {file = "rapidfuzz-1.8.0-cp39-cp39-win32.whl", hash = "sha256:9391abf1121df831316222f28cea37397a0f72bd7978f3be6e7da29a7821e4e5"},
- {file = "rapidfuzz-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:9eeca1b436042b5523dcf314f5822b1131597898c1d967f140d1917541a8a3d1"},
- {file = "rapidfuzz-1.8.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:a01f2495aca479b49d3b3a8863d6ba9bea2043447a1ced74ae5ec5270059cbc1"},
- {file = "rapidfuzz-1.8.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:b7d4b1a5d16817f8cdb34365c7b58ae22d5cf1b3207720bb2fa0b55968bdb034"},
- {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c738d0d7f1744646d48d19b4c775926082bcefebd2460f45ca383a0e882f5672"},
- {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0fb9c6078c17c12b52e66b7d0a2a1674f6bbbdc6a76e454c8479b95147018123"},
- {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:1482b385d83670eb069577c9667f72b41eec4f005aee32f1a4ff4e71e88afde2"},
- {file = "rapidfuzz-1.8.0.tar.gz", hash = "sha256:83fff37acf0367314879231264169dcbc5e7de969a94f4b82055d06a7fddab9a"},
+ {file = "rapidfuzz-2.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b306b4a1d42a8dfd5f3daff9a82853f1541e5c74a2ec34515a5e5cd51f3c7307"},
+ {file = "rapidfuzz-2.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2ee380254d8b29d0b0f47a020e7f16375a4d97164b8071b3f94d5c684d744093"},
+ {file = "rapidfuzz-2.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac993b8760c5308d885c300355e2c537daf0696ebc5d30436af83818978e661c"},
+ {file = "rapidfuzz-2.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06a394e475316aeddbf4bf9691aabf4825f8c1acf87b49abbb7b9dad7e555ae"},
+ {file = "rapidfuzz-2.0.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79883fcfc3e550b356d45ac2bf1af391161f9ddb64b1ed504f9a94086b824709"},
+ {file = "rapidfuzz-2.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d44d74ace68b3ec6dee4501188c74f124da8c940877989baf9f672d51368e171"},
+ {file = "rapidfuzz-2.0.7-cp310-cp310-win32.whl", hash = "sha256:9ec9fd78d40f392cd4ce91dbb17477cd07740d0cb0b7bf44e9ab67c16ee3d5ce"},
+ {file = "rapidfuzz-2.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:7983ed01b0ac5343bea4d737024576a86a8c68f3c8d811498eb0facf8d3bafc1"},
+ {file = "rapidfuzz-2.0.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:49fd3d2a789abc30c811d6ed81db1c5f143caf5e975720bf9ab62c920253d5e9"},
+ {file = "rapidfuzz-2.0.7-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:636489517bbd0786f300948f8eba59635f2fb781ecbc2ed19deba3426ee32ab6"},
+ {file = "rapidfuzz-2.0.7-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c6a47418b86a6b8267a89f253e2b14f9aa8b4b559141b15f8c8a9769d19b109"},
+ {file = "rapidfuzz-2.0.7-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fe01ca2cbdb2aee6f80c1fc3a82fa69ee9ef9c44f085a725113b5d12209e05d"},
+ {file = "rapidfuzz-2.0.7-cp36-cp36m-win32.whl", hash = "sha256:8157406a1b44cd742d65c65ca8345e47fcc8642148a970626b886fb52b3abd1d"},
+ {file = "rapidfuzz-2.0.7-cp36-cp36m-win_amd64.whl", hash = "sha256:3062ea2a0481196376e364470c682d5ebc22eb5d4c114350f05f079119ea61b8"},
+ {file = "rapidfuzz-2.0.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cbfc3fcbbd00edf7f917ad0d6bf46350c64a9910c14d05e1936d436170f2531d"},
+ {file = "rapidfuzz-2.0.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae15eb44e014101b208c97a253d850d6fb4a8465f3c9ee8be3508b03135ad0e7"},
+ {file = "rapidfuzz-2.0.7-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9224115aae07d42b9250d8ca58d5568cab2ddd8720c551aa7de9dcec661ee86"},
+ {file = "rapidfuzz-2.0.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42bc2cf64ebbf2a80e6fd03353679de17118a431dce358cfadc7cdb72ac9510a"},
+ {file = "rapidfuzz-2.0.7-cp37-cp37m-win32.whl", hash = "sha256:34416ee6265dfa1415e9f10c7dafe6a85296117f534f67d00021eeaa661c8d9e"},
+ {file = "rapidfuzz-2.0.7-cp37-cp37m-win_amd64.whl", hash = "sha256:4044ef50f020f16f99b5979784b648b7ab90cd6bd0d275359818a2c155f9c01d"},
+ {file = "rapidfuzz-2.0.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:1332fb51345e431ba39e075c3dbc222bb9770f0e73c097c7a65c8c2ea331004c"},
+ {file = "rapidfuzz-2.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2ac560a603d0d1b9d70cc0a376d1adf57ece4195e61351d410e0c7b0fa280cbe"},
+ {file = "rapidfuzz-2.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0fd757a38e14f247d929af7df6762aee2082f7a6882c85a31f17b09a450bbb5e"},
+ {file = "rapidfuzz-2.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723a48d5937e4a558fb5df553b3d0e0b3cc05de7f7a8d43a920682b796010ab5"},
+ {file = "rapidfuzz-2.0.7-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c9b344e3f69c5b69ae0c96411d3ee1dab02ec49124471e44ce2a16f6446fa6d"},
+ {file = "rapidfuzz-2.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7f34f905a0e9fa01cf26b9208daac6523708f9439958397b21b95c6c4fe508b"},
+ {file = "rapidfuzz-2.0.7-cp38-cp38-win32.whl", hash = "sha256:a95a45939cbd035c2d4779765a81485215a12fa5f1b912c2738374fad93e753d"},
+ {file = "rapidfuzz-2.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:14234ecc57e1799e24c9dcd230bba02630c4f38ca60c0eb075452313da8e0e95"},
+ {file = "rapidfuzz-2.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9959374974fb96d3941334f5f8caeea971ea9718279514748c53d381146c5a7"},
+ {file = "rapidfuzz-2.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2a988b5ff46823e0d5e14b4a1cce3ef13024009115df61d1d3b7ba14678f421"},
+ {file = "rapidfuzz-2.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:603d179205972ebb5b01e7a84ead465d08813d50401216d5cc81fc2589e2c957"},
+ {file = "rapidfuzz-2.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a934734aa247f57c683932ae0d38653063b2d97540598b551294b40ff242bd62"},
+ {file = "rapidfuzz-2.0.7-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c42064174035f3633f4a815c38a76514875ca8531fac3f992202a41d1f338a41"},
+ {file = "rapidfuzz-2.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46a46b8bab2ceee4877dfb281e94a43197b118d96cb04325e07540f7f9c57324"},
+ {file = "rapidfuzz-2.0.7-cp39-cp39-win32.whl", hash = "sha256:1f892f3dd0acfbc2ba0b90d72cac42dd468ac9a8f7ac2179c91c29c22a4f7960"},
+ {file = "rapidfuzz-2.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:233024373cb77dc2ef510b5fccac0429edb3294ea631ad777a7e3ff614501578"},
+ {file = "rapidfuzz-2.0.7-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be8121175e7096062a312b73823385389635c4dec50a9e0496b29c4ba0b50362"},
+ {file = "rapidfuzz-2.0.7-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad5282cf9921c6dbfe1c58e5af05c3014eabc20afd8fafcc0e6a56e9263875a0"},
+ {file = "rapidfuzz-2.0.7-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c67d650e25a7c281127865cc50c3588d5319200c8a11837df51ab3eead7cf066"},
+ {file = "rapidfuzz-2.0.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07ccd298a24de2dadead47e75f23ff747ed3ee551964a8401ccae31a577cebb1"},
+ {file = "rapidfuzz-2.0.7-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1e70ec13c00a9f28cce76a29eb5c4e6aeb5dadb9ddb35b74dfe05d503c09a4a"},
+ {file = "rapidfuzz-2.0.7-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c47fda63c0d9d8275b319cdc226f96b3f1c16a395409442bff566b6de6b7cac9"},
+ {file = "rapidfuzz-2.0.7.tar.gz", hash = "sha256:93bf42784fd74ebf1a8e89ca1596e9bea7f3ac4a61b825ecc6eb2d9893ad6844"},
]
redis = [
- {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"},
- {file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"},
+ {file = "redis-4.3.1-py3-none-any.whl", hash = "sha256:84316970995a7adb907a56754d2b92d88fc2d252963dc5ac34c88f0f1a22c25d"},
+ {file = "redis-4.3.1.tar.gz", hash = "sha256:94b617b4cd296e94991146f66fc5559756fbefe9493604f0312e4d3298ac63e9"},
]
regex = [
- {file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"},
- {file = "regex-2021.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711"},
- {file = "regex-2021.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11"},
- {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968"},
- {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0"},
- {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4"},
- {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a"},
- {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7"},
- {file = "regex-2021.4.4-cp36-cp36m-win32.whl", hash = "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29"},
- {file = "regex-2021.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79"},
- {file = "regex-2021.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8"},
- {file = "regex-2021.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31"},
- {file = "regex-2021.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a"},
- {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5"},
- {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82"},
- {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765"},
- {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e"},
- {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439"},
- {file = "regex-2021.4.4-cp37-cp37m-win32.whl", hash = "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d"},
- {file = "regex-2021.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3"},
- {file = "regex-2021.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500"},
- {file = "regex-2021.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14"},
- {file = "regex-2021.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480"},
- {file = "regex-2021.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc"},
- {file = "regex-2021.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093"},
- {file = "regex-2021.4.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10"},
- {file = "regex-2021.4.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f"},
- {file = "regex-2021.4.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87"},
- {file = "regex-2021.4.4-cp38-cp38-win32.whl", hash = "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac"},
- {file = "regex-2021.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2"},
- {file = "regex-2021.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17"},
- {file = "regex-2021.4.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605"},
- {file = "regex-2021.4.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9"},
- {file = "regex-2021.4.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7"},
- {file = "regex-2021.4.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8"},
- {file = "regex-2021.4.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed"},
- {file = "regex-2021.4.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c"},
- {file = "regex-2021.4.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"},
- {file = "regex-2021.4.4-cp39-cp39-win32.whl", hash = "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6"},
- {file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"},
- {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"},
+ {file = "regex-2022.3.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:42eb13b93765c6698a5ab3bcd318d8c39bb42e5fa8a7fcf7d8d98923f3babdb1"},
+ {file = "regex-2022.3.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9beb03ff6fe509d6455971c2489dceb31687b38781206bcec8e68bdfcf5f1db2"},
+ {file = "regex-2022.3.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0a5a1fdc9f148a8827d55b05425801acebeeefc9e86065c7ac8b8cc740a91ff"},
+ {file = "regex-2022.3.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb374a2a4dba7c4be0b19dc7b1adc50e6c2c26c3369ac629f50f3c198f3743a4"},
+ {file = "regex-2022.3.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c33ce0c665dd325200209340a88438ba7a470bd5f09f7424e520e1a3ff835b52"},
+ {file = "regex-2022.3.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04c09b9651fa814eeeb38e029dc1ae83149203e4eeb94e52bb868fadf64852bc"},
+ {file = "regex-2022.3.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab5d89cfaf71807da93c131bb7a19c3e19eaefd613d14f3bce4e97de830b15df"},
+ {file = "regex-2022.3.15-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0e2630ae470d6a9f8e4967388c1eda4762706f5750ecf387785e0df63a4cc5af"},
+ {file = "regex-2022.3.15-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:df037c01d68d1958dad3463e2881d3638a0d6693483f58ad41001aa53a83fcea"},
+ {file = "regex-2022.3.15-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:940570c1a305bac10e8b2bc934b85a7709c649317dd16520471e85660275083a"},
+ {file = "regex-2022.3.15-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7f63877c87552992894ea1444378b9c3a1d80819880ae226bb30b04789c0828c"},
+ {file = "regex-2022.3.15-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:3e265b388cc80c7c9c01bb4f26c9e536c40b2c05b7231fbb347381a2e1c8bf43"},
+ {file = "regex-2022.3.15-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:058054c7a54428d5c3e3739ac1e363dc9347d15e64833817797dc4f01fb94bb8"},
+ {file = "regex-2022.3.15-cp310-cp310-win32.whl", hash = "sha256:76435a92e444e5b8f346aed76801db1c1e5176c4c7e17daba074fbb46cb8d783"},
+ {file = "regex-2022.3.15-cp310-cp310-win_amd64.whl", hash = "sha256:174d964bc683b1e8b0970e1325f75e6242786a92a22cedb2a6ec3e4ae25358bd"},
+ {file = "regex-2022.3.15-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6e1d8ed9e61f37881c8db383a124829a6e8114a69bd3377a25aecaeb9b3538f8"},
+ {file = "regex-2022.3.15-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b52771f05cff7517f7067fef19ffe545b1f05959e440d42247a17cd9bddae11b"},
+ {file = "regex-2022.3.15-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:673f5a393d603c34477dbad70db30025ccd23996a2d0916e942aac91cc42b31a"},
+ {file = "regex-2022.3.15-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8923e1c5231549fee78ff9b2914fad25f2e3517572bb34bfaa3aea682a758683"},
+ {file = "regex-2022.3.15-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:764e66a0e382829f6ad3bbce0987153080a511c19eb3d2f8ead3f766d14433ac"},
+ {file = "regex-2022.3.15-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd00859291658fe1fda48a99559fb34da891c50385b0bfb35b808f98956ef1e7"},
+ {file = "regex-2022.3.15-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:aa2ce79f3889720b46e0aaba338148a1069aea55fda2c29e0626b4db20d9fcb7"},
+ {file = "regex-2022.3.15-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:34bb30c095342797608727baf5c8aa122406aa5edfa12107b8e08eb432d4c5d7"},
+ {file = "regex-2022.3.15-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:25ecb1dffc5e409ca42f01a2b2437f93024ff1612c1e7983bad9ee191a5e8828"},
+ {file = "regex-2022.3.15-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:aa5eedfc2461c16a092a2fabc5895f159915f25731740c9152a1b00f4bcf629a"},
+ {file = "regex-2022.3.15-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:7d1a6e403ac8f1d91d8f51c441c3f99367488ed822bda2b40836690d5d0059f5"},
+ {file = "regex-2022.3.15-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:3e4d710ff6539026e49f15a3797c6b1053573c2b65210373ef0eec24480b900b"},
+ {file = "regex-2022.3.15-cp36-cp36m-win32.whl", hash = "sha256:0100f0ded953b6b17f18207907159ba9be3159649ad2d9b15535a74de70359d3"},
+ {file = "regex-2022.3.15-cp36-cp36m-win_amd64.whl", hash = "sha256:f320c070dea3f20c11213e56dbbd7294c05743417cde01392148964b7bc2d31a"},
+ {file = "regex-2022.3.15-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fc8c7958d14e8270171b3d72792b609c057ec0fa17d507729835b5cff6b7f69a"},
+ {file = "regex-2022.3.15-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ca6dcd17f537e9f3793cdde20ac6076af51b2bd8ad5fe69fa54373b17b48d3c"},
+ {file = "regex-2022.3.15-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0214ff6dff1b5a4b4740cfe6e47f2c4c92ba2938fca7abbea1359036305c132f"},
+ {file = "regex-2022.3.15-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a98ae493e4e80b3ded6503ff087a8492db058e9c68de371ac3df78e88360b374"},
+ {file = "regex-2022.3.15-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b1cc70e31aacc152a12b39245974c8fccf313187eead559ee5966d50e1b5817"},
+ {file = "regex-2022.3.15-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4829db3737480a9d5bfb1c0320c4ee13736f555f53a056aacc874f140e98f64"},
+ {file = "regex-2022.3.15-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:303b15a3d32bf5fe5a73288c316bac5807587f193ceee4eb6d96ee38663789fa"},
+ {file = "regex-2022.3.15-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:dc7b7c16a519d924c50876fb152af661a20749dcbf653c8759e715c1a7a95b18"},
+ {file = "regex-2022.3.15-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ce3057777a14a9a1399b81eca6a6bfc9612047811234398b84c54aeff6d536ea"},
+ {file = "regex-2022.3.15-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:48081b6bff550fe10bcc20c01cf6c83dbca2ccf74eeacbfac240264775fd7ecf"},
+ {file = "regex-2022.3.15-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dcbb7665a9db9f8d7642171152c45da60e16c4f706191d66a1dc47ec9f820aed"},
+ {file = "regex-2022.3.15-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c155a1a80c5e7a8fa1d9bb1bf3c8a953532b53ab1196092749bafb9d3a7cbb60"},
+ {file = "regex-2022.3.15-cp37-cp37m-win32.whl", hash = "sha256:04b5ee2b6d29b4a99d38a6469aa1db65bb79d283186e8460542c517da195a8f6"},
+ {file = "regex-2022.3.15-cp37-cp37m-win_amd64.whl", hash = "sha256:797437e6024dc1589163675ae82f303103063a0a580c6fd8d0b9a0a6708da29e"},
+ {file = "regex-2022.3.15-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8afcd1c2297bc989dceaa0379ba15a6df16da69493635e53431d2d0c30356086"},
+ {file = "regex-2022.3.15-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0066a6631c92774391f2ea0f90268f0d82fffe39cb946f0f9c6b382a1c61a5e5"},
+ {file = "regex-2022.3.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8248f19a878c72d8c0a785a2cd45d69432e443c9f10ab924c29adda77b324ae"},
+ {file = "regex-2022.3.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d1f3ea0d1924feb4cf6afb2699259f658a08ac6f8f3a4a806661c2dfcd66db1"},
+ {file = "regex-2022.3.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:794a6bc66c43db8ed06698fc32aaeaac5c4812d9f825e9589e56f311da7becd9"},
+ {file = "regex-2022.3.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d1445824944e642ffa54c4f512da17a953699c563a356d8b8cbdad26d3b7598"},
+ {file = "regex-2022.3.15-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f553a1190ae6cd26e553a79f6b6cfba7b8f304da2071052fa33469da075ea625"},
+ {file = "regex-2022.3.15-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:75a5e6ce18982f0713c4bac0704bf3f65eed9b277edd3fb9d2b0ff1815943327"},
+ {file = "regex-2022.3.15-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f16cf7e4e1bf88fecf7f41da4061f181a6170e179d956420f84e700fb8a3fd6b"},
+ {file = "regex-2022.3.15-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:dad3991f0678facca1a0831ec1ddece2eb4d1dd0f5150acb9440f73a3b863907"},
+ {file = "regex-2022.3.15-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:491fc754428514750ab21c2d294486223ce7385446f2c2f5df87ddbed32979ae"},
+ {file = "regex-2022.3.15-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:6504c22c173bb74075d7479852356bb7ca80e28c8e548d4d630a104f231e04fb"},
+ {file = "regex-2022.3.15-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:01c913cf573d1da0b34c9001a94977273b5ee2fe4cb222a5d5b320f3a9d1a835"},
+ {file = "regex-2022.3.15-cp38-cp38-win32.whl", hash = "sha256:029e9e7e0d4d7c3446aa92474cbb07dafb0b2ef1d5ca8365f059998c010600e6"},
+ {file = "regex-2022.3.15-cp38-cp38-win_amd64.whl", hash = "sha256:947a8525c0a95ba8dc873191f9017d1b1e3024d4dc757f694e0af3026e34044a"},
+ {file = "regex-2022.3.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:591d4fba554f24bfa0421ba040cd199210a24301f923ed4b628e1e15a1001ff4"},
+ {file = "regex-2022.3.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b9809404528a999cf02a400ee5677c81959bc5cb938fdc696b62eb40214e3632"},
+ {file = "regex-2022.3.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f08a7e4d62ea2a45557f561eea87c907222575ca2134180b6974f8ac81e24f06"},
+ {file = "regex-2022.3.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a86cac984da35377ca9ac5e2e0589bd11b3aebb61801204bd99c41fac516f0d"},
+ {file = "regex-2022.3.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:286908cbe86b1a0240a867aecfe26a439b16a1f585d2de133540549831f8e774"},
+ {file = "regex-2022.3.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b7494df3fdcc95a1f76cf134d00b54962dd83189520fd35b8fcd474c0aa616d"},
+ {file = "regex-2022.3.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b1ceede92400b3acfebc1425937454aaf2c62cd5261a3fabd560c61e74f6da3"},
+ {file = "regex-2022.3.15-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0317eb6331146c524751354ebef76a7a531853d7207a4d760dfb5f553137a2a4"},
+ {file = "regex-2022.3.15-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9c144405220c5ad3f5deab4c77f3e80d52e83804a6b48b6bed3d81a9a0238e4c"},
+ {file = "regex-2022.3.15-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5b2e24f3ae03af3d8e8e6d824c891fea0ca9035c5d06ac194a2700373861a15c"},
+ {file = "regex-2022.3.15-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f2c53f3af011393ab5ed9ab640fa0876757498aac188f782a0c620e33faa2a3d"},
+ {file = "regex-2022.3.15-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:060f9066d2177905203516c62c8ea0066c16c7342971d54204d4e51b13dfbe2e"},
+ {file = "regex-2022.3.15-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:530a3a16e57bd3ea0dff5ec2695c09632c9d6c549f5869d6cf639f5f7153fb9c"},
+ {file = "regex-2022.3.15-cp39-cp39-win32.whl", hash = "sha256:78ce90c50d0ec970bd0002462430e00d1ecfd1255218d52d08b3a143fe4bde18"},
+ {file = "regex-2022.3.15-cp39-cp39-win_amd64.whl", hash = "sha256:c5adc854764732dbd95a713f2e6c3e914e17f2ccdc331b9ecb777484c31f73b6"},
+ {file = "regex-2022.3.15.tar.gz", hash = "sha256:0a7b75cc7bb4cc0334380053e4671c560e31272c9d2d5a6c4b8e9ae2c9bd0f82"},
]
requests = [
- {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
- {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
+ {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
+ {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
+]
+requests-file = [
+ {file = "requests-file-1.5.1.tar.gz", hash = "sha256:07d74208d3389d01c38ab89ef403af0cfec63957d53a0081d8eca738d0247d8e"},
+ {file = "requests_file-1.5.1-py2.py3-none-any.whl", hash = "sha256:dfe5dae75c12481f68ba353183c53a65e6044c923e64c24b2209f6c7570ca953"},
]
sentry-sdk = [
- {file = "sentry-sdk-1.4.3.tar.gz", hash = "sha256:b9844751e40710e84a457c5bc29b21c383ccb2b63d76eeaad72f7f1c808c8828"},
- {file = "sentry_sdk-1.4.3-py2.py3-none-any.whl", hash = "sha256:c091cc7115ff25fe3a0e410dbecd7a996f81a3f6137d2272daef32d6c3cfa6dc"},
+ {file = "sentry-sdk-1.5.8.tar.gz", hash = "sha256:38fd16a92b5ef94203db3ece10e03bdaa291481dd7e00e77a148aa0302267d47"},
+ {file = "sentry_sdk-1.5.8-py2.py3-none-any.whl", hash = "sha256:32af1a57954576709242beb8c373b3dbde346ac6bd616921def29d68846fb8c3"},
]
sgmllib3k = [
{file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"},
@@ -1914,117 +2198,186 @@ six = [
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
snowballstemmer = [
- {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"},
- {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"},
+ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
+ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
]
sortedcontainers = [
{file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
{file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
]
soupsieve = [
- {file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"},
- {file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"},
+ {file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"},
+ {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"},
]
statsd = [
{file = "statsd-3.3.0-py2.py3-none-any.whl", hash = "sha256:c610fb80347fca0ef62666d241bce64184bd7cc1efe582f9690e045c25535eaa"},
{file = "statsd-3.3.0.tar.gz", hash = "sha256:e3e6db4c246f7c59003e51c9720a51a7f39a396541cb9b147ff4b14d15b5dd1f"},
]
taskipy = [
- {file = "taskipy-1.7.0-py3-none-any.whl", hash = "sha256:9e284c10898e9dee01a3e72220b94b192b1daa0f560271503a6df1da53d03844"},
- {file = "taskipy-1.7.0.tar.gz", hash = "sha256:960e480b1004971e76454ecd1a0484e640744a30073a1069894a311467f85ed8"},
+ {file = "taskipy-1.10.1-py3-none-any.whl", hash = "sha256:9b38333654da487b6d16de6fa330b7629d1935d1e74819ba4c5f17a1c372d37b"},
+ {file = "taskipy-1.10.1.tar.gz", hash = "sha256:6fa0b11c43d103e376063e90be31d87b435aad50fb7dc1c9a2de9b60a85015ed"},
]
testfixtures = [
- {file = "testfixtures-6.18.3-py2.py3-none-any.whl", hash = "sha256:6ddb7f56a123e1a9339f130a200359092bd0a6455e31838d6c477e8729bb7763"},
- {file = "testfixtures-6.18.3.tar.gz", hash = "sha256:2600100ae96ffd082334b378e355550fef8b4a529a6fa4c34f47130905c7426d"},
+ {file = "testfixtures-6.18.5-py2.py3-none-any.whl", hash = "sha256:7de200e24f50a4a5d6da7019fb1197aaf5abd475efb2ec2422fdcf2f2eb98c1d"},
+ {file = "testfixtures-6.18.5.tar.gz", hash = "sha256:02dae883f567f5b70fd3ad3c9eefb95912e78ac90be6c7444b5e2f46bf572c84"},
+]
+tldextract = [
+ {file = "tldextract-3.2.0-py3-none-any.whl", hash = "sha256:427703b65db54644f7b81d3dcb79bf355c1a7c28a12944e5cc6787531ccc828a"},
+ {file = "tldextract-3.2.0.tar.gz", hash = "sha256:3d4b6a2105600b7d0290ea237bf30b6b0dc763e50fcbe40e849a019bd6dbcbff"},
]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
-typing-extensions = [
- {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
- {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
- {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
+tomli = [
+ {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"},
+ {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"},
]
urllib3 = [
- {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"},
- {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"},
+ {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"},
+ {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"},
]
virtualenv = [
- {file = "virtualenv-20.8.1-py2.py3-none-any.whl", hash = "sha256:10062e34c204b5e4ec5f62e6ef2473f8ba76513a9a617e873f1f8fb4a519d300"},
- {file = "virtualenv-20.8.1.tar.gz", hash = "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8"},
+ {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"},
+ {file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"},
+]
+wrapt = [
+ {file = "wrapt-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3"},
+ {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef"},
+ {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28"},
+ {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59"},
+ {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87"},
+ {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1"},
+ {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b"},
+ {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462"},
+ {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1"},
+ {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"},
+ {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"},
+ {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4"},
+ {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069"},
+ {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310"},
+ {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f"},
+ {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656"},
+ {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c"},
+ {file = "wrapt-1.14.1-cp310-cp310-win32.whl", hash = "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8"},
+ {file = "wrapt-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164"},
+ {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907"},
+ {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3"},
+ {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3"},
+ {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d"},
+ {file = "wrapt-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7"},
+ {file = "wrapt-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00"},
+ {file = "wrapt-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4"},
+ {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1"},
+ {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1"},
+ {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff"},
+ {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d"},
+ {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1"},
+ {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569"},
+ {file = "wrapt-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed"},
+ {file = "wrapt-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471"},
+ {file = "wrapt-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248"},
+ {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68"},
+ {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d"},
+ {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77"},
+ {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7"},
+ {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015"},
+ {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a"},
+ {file = "wrapt-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853"},
+ {file = "wrapt-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c"},
+ {file = "wrapt-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456"},
+ {file = "wrapt-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f"},
+ {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc"},
+ {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1"},
+ {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af"},
+ {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b"},
+ {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0"},
+ {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57"},
+ {file = "wrapt-1.14.1-cp38-cp38-win32.whl", hash = "sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5"},
+ {file = "wrapt-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d"},
+ {file = "wrapt-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383"},
+ {file = "wrapt-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7"},
+ {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86"},
+ {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735"},
+ {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b"},
+ {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3"},
+ {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3"},
+ {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe"},
+ {file = "wrapt-1.14.1-cp39-cp39-win32.whl", hash = "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5"},
+ {file = "wrapt-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb"},
+ {file = "wrapt-1.14.1.tar.gz", hash = "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d"},
]
yarl = [
- {file = "yarl-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e35d8230e4b08d86ea65c32450533b906a8267a87b873f2954adeaecede85169"},
- {file = "yarl-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb4b3f277880c314e47720b4b6bb2c85114ab3c04c5442c9bc7006b3787904d8"},
- {file = "yarl-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7015dcedb91d90a138eebdc7e432aec8966e0147ab2a55f2df27b1904fa7291"},
- {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb3e478175e15e00d659fb0354a6a8db71a7811a2a5052aed98048bc972e5d2b"},
- {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8c409aa3a7966647e7c1c524846b362a6bcbbe120bf8a176431f940d2b9a2e"},
- {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b22ea41c7e98170474a01e3eded1377d46b2dfaef45888a0005c683eaaa49285"},
- {file = "yarl-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a7dfc46add4cfe5578013dbc4127893edc69fe19132d2836ff2f6e49edc5ecd6"},
- {file = "yarl-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:82ff6f85f67500a4f74885d81659cd270eb24dfe692fe44e622b8a2fd57e7279"},
- {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f3cd2158b2ed0fb25c6811adfdcc47224efe075f2d68a750071dacc03a7a66e4"},
- {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:59c0f13f9592820c51280d1cf811294d753e4a18baf90f0139d1dc93d4b6fc5f"},
- {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7f7655ad83d1a8afa48435a449bf2f3009293da1604f5dd95b5ddcf5f673bd69"},
- {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aa9f0d9b62d15182341b3e9816582f46182cab91c1a57b2d308b9a3c4e2c4f78"},
- {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fdd1b90c225a653b1bd1c0cae8edf1957892b9a09c8bf7ee6321eeb8208eac0f"},
- {file = "yarl-1.7.0-cp310-cp310-win32.whl", hash = "sha256:7c8d0bb76eabc5299db203e952ec55f8f4c53f08e0df4285aac8c92bd9e12675"},
- {file = "yarl-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:622a36fa779efb4ff9eff5fe52730ff17521431379851a31e040958fc251670c"},
- {file = "yarl-1.7.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d461b7a8e139b9e4b41f62eb417ffa0b98d1c46d4caf14c845e6a3b349c0bb1"},
- {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81cfacdd1e40bc931b5519499342efa388d24d262c30a3d31187bfa04f4a7001"},
- {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:821b978f2152be7695d4331ef0621d207aedf9bbd591ba23a63412a3efc29a01"},
- {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b64bd24c8c9a487f4a12260dc26732bf41028816dbf0c458f17864fbebdb3131"},
- {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:98c9ddb92b60a83c21be42c776d3d9d5ec632a762a094c41bda37b7dfbd2cd83"},
- {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a532d75ca74431c053a88a802e161fb3d651b8bf5821a3440bc3616e38754583"},
- {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:053e09817eafb892e94e172d05406c1b3a22a93bc68f6eff5198363a3d764459"},
- {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:98c51f02d542945d306c8e934aa2c1e66ba5e9c1c86b5bf37f3a51c8a747067e"},
- {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:15ec41a5a5fdb7bace6d7b16701f9440007a82734f69127c0fbf6d87e10f4a1e"},
- {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:a7f08819dba1e1255d6991ed37448a1bf4b1352c004bcd899b9da0c47958513d"},
- {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8e3ffab21db0542ffd1887f3b9575ddd58961f2cf61429cb6458afc00c4581e0"},
- {file = "yarl-1.7.0-cp36-cp36m-win32.whl", hash = "sha256:50127634f519b2956005891507e3aa4ac345f66a7ea7bbc2d7dcba7401f41898"},
- {file = "yarl-1.7.0-cp36-cp36m-win_amd64.whl", hash = "sha256:36ec44f15193f6d5288d42ebb8e751b967ebdfb72d6830983838d45ab18edb4f"},
- {file = "yarl-1.7.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ec1b5a25a25c880c976d0bb3d107def085bb08dbb3db7f4442e0a2b980359d24"},
- {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b36f5a63c891f813c6f04ef19675b382efc190fd5ce7e10ab19386d2548bca06"},
- {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38173b8c3a29945e7ecade9a3f6ff39581eee8201338ee6a2c8882db5df3e806"},
- {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ba402f32184f0b405fb281b93bd0d8ab7e3257735b57b62a6ed2e94cdf4fe50"},
- {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:be52bc5208d767cdd8308a9e93059b3b36d1e048fecbea0e0346d0d24a76adc0"},
- {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:08c2044a956f4ef30405f2f433ce77f1f57c2c773bf81ae43201917831044d5a"},
- {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:484d61c047c45670ef5967653a1d0783e232c54bf9dd786a7737036828fa8d54"},
- {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b7de92a4af85cfcaf4081f8aa6165b1d63ee5de150af3ee85f954145f93105a7"},
- {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:376e41775aab79c5575534924a386c8e0f1a5d91db69fc6133fd27a489bcaf10"},
- {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:8a8b10d0e7bac154f959b709fcea593cda527b234119311eb950096653816a86"},
- {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:f46cd4c43e6175030e2a56def8f1d83b64e6706eeb2bb9ab0ef4756f65eab23f"},
- {file = "yarl-1.7.0-cp37-cp37m-win32.whl", hash = "sha256:b28cfb46140efe1a6092b8c5c4994a1fe70dc83c38fbcea4992401e0c6fb9cce"},
- {file = "yarl-1.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9624154ec9c02a776802da1086eed7f5034bd1971977f5146233869c2ac80297"},
- {file = "yarl-1.7.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:69945d13e1bbf81784a9bc48824feb9cd66491e6a503d4e83f6cd7c7cc861361"},
- {file = "yarl-1.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:46a742ed9e363bd01be64160ce7520e92e11989bd4cb224403cfd31c101cc83d"},
- {file = "yarl-1.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb4ff1ac7cb4500f43581b3f4cbd627d702143aa6be1fdc1fa3ebffaf4dc1be5"},
- {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ad51e17cd65ea3debb0e10f0120cf8dd987c741fe423ed2285087368090b33d"},
- {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e37786ea89a5d3ffbbf318ea9790926f8dfda83858544f128553c347ad143c6"},
- {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c63c1e208f800daad71715786bfeb1cecdc595d87e2e9b1cd234fd6e597fd71d"},
- {file = "yarl-1.7.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:91cbe24300c11835ef186436363352b3257db7af165e0a767f4f17aa25761388"},
- {file = "yarl-1.7.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e510dbec7c59d32eaa61ffa48173d5e3d7170a67f4a03e8f5e2e9e3971aca622"},
- {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3def6e681cc02397e5d8141ee97b41d02932b2bcf0fb34532ad62855eab7c60e"},
- {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:263c81b94e6431942b27f6f671fa62f430a0a5c14bb255f2ab69eeb9b2b66ff7"},
- {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e78c91faefe88d601ddd16e3882918dbde20577a2438e2320f8239c8b7507b8f"},
- {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:22b2430c49713bfb2f0a0dd4a8d7aab218b28476ba86fd1c78ad8899462cbcf2"},
- {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2e7ad9db939082f5d0b9269cfd92c025cb8f2fbbb1f1b9dc5a393c639db5bd92"},
- {file = "yarl-1.7.0-cp38-cp38-win32.whl", hash = "sha256:3a31e4a8dcb1beaf167b7e7af61b88cb961b220db8d3ba1c839723630e57eef7"},
- {file = "yarl-1.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:d579957439933d752358c6a300c93110f84aae67b63dd0c19dde6ecbf4056f6b"},
- {file = "yarl-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:87721b549505a546eb003252185103b5ec8147de6d3ad3714d148a5a67b6fe53"},
- {file = "yarl-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1fa866fa24d9f4108f9e58ea8a2135655419885cdb443e36b39a346e1181532"},
- {file = "yarl-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1d3b8449dfedfe94eaff2b77954258b09b24949f6818dfa444b05dbb05ae1b7e"},
- {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2372e350794ce8b9f810feb094c606b7e0e4aa6807141ac4fadfe5ddd75bb0"},
- {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a06d9d0b9a97fa99b84fee71d9dd11e69e21ac8a27229089f07b5e5e50e8d63c"},
- {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3455c2456d6307bcfa80bc1157b8603f7d93573291f5bdc7144489ca0df4628"},
- {file = "yarl-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d30d67e3486aea61bb2cbf7cf81385364c2e4f7ce7469a76ed72af76a5cdfe6b"},
- {file = "yarl-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c18a4b286e8d780c3a40c31d7b79836aa93b720f71d5743f20c08b7e049ca073"},
- {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d54c925396e7891666cabc0199366ca55b27d003393465acef63fd29b8b7aa92"},
- {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:64773840952de17851a1c7346ad7f71688c77e74248d1f0bc230e96680f84028"},
- {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:acbf1756d9dc7cd0ae943d883be72e84e04396f6c2ff93a6ddeca929d562039f"},
- {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:2e48f27936aa838939c798f466c851ba4ae79e347e8dfce43b009c64b930df12"},
- {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1beef4734ca1ad40a9d8c6b20a76ab46e3a2ed09f38561f01e4aa2ea82cafcef"},
- {file = "yarl-1.7.0-cp39-cp39-win32.whl", hash = "sha256:8ee78c9a5f3c642219d4607680a4693b59239c27a3aa608b64ef79ddc9698039"},
- {file = "yarl-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:d750503682605088a14d29a4701548c15c510da4f13c8b17409c4097d5b04c52"},
- {file = "yarl-1.7.0.tar.gz", hash = "sha256:8e7ebaf62e19c2feb097ffb7c94deb0f0c9fab52590784c8cd679d30ab009162"},
+ {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"},
+ {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"},
+ {file = "yarl-1.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1d0894f238763717bdcfea74558c94e3bc34aeacd3351d769460c1a586a8b05"},
+ {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4b95b7e00c6635a72e2d00b478e8a28bfb122dc76349a06e20792eb53a523"},
+ {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c145ab54702334c42237a6c6c4cc08703b6aa9b94e2f227ceb3d477d20c36c63"},
+ {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca56f002eaf7998b5fcf73b2421790da9d2586331805f38acd9997743114e98"},
+ {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d3d5ad8ea96bd6d643d80c7b8d5977b4e2fb1bab6c9da7322616fd26203d125"},
+ {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:167ab7f64e409e9bdd99333fe8c67b5574a1f0495dcfd905bc7454e766729b9e"},
+ {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:95a1873b6c0dd1c437fb3bb4a4aaa699a48c218ac7ca1e74b0bee0ab16c7d60d"},
+ {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6152224d0a1eb254f97df3997d79dadd8bb2c1a02ef283dbb34b97d4f8492d23"},
+ {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bb7d54b8f61ba6eee541fba4b83d22b8a046b4ef4d8eb7f15a7e35db2e1e245"},
+ {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9c1f083e7e71b2dd01f7cd7434a5f88c15213194df38bc29b388ccdf1492b739"},
+ {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f44477ae29025d8ea87ec308539f95963ffdc31a82f42ca9deecf2d505242e72"},
+ {file = "yarl-1.7.2-cp310-cp310-win32.whl", hash = "sha256:cff3ba513db55cc6a35076f32c4cdc27032bd075c9faef31fec749e64b45d26c"},
+ {file = "yarl-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:c9c6d927e098c2d360695f2e9d38870b2e92e0919be07dbe339aefa32a090265"},
+ {file = "yarl-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b4c77d92d56a4c5027572752aa35082e40c561eec776048330d2907aead891d"},
+ {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01a89a44bb672c38f42b49cdb0ad667b116d731b3f4c896f72302ff77d71656"},
+ {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c19324a1c5399b602f3b6e7db9478e5b1adf5cf58901996fc973fe4fccd73eed"},
+ {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abddf0b8e41445426d29f955b24aeecc83fa1072be1be4e0d194134a7d9baee"},
+ {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a1a9fe17621af43e9b9fcea8bd088ba682c8192d744b386ee3c47b56eaabb2c"},
+ {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b0915ee85150963a9504c10de4e4729ae700af11df0dc5550e6587ed7891e92"},
+ {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:29e0656d5497733dcddc21797da5a2ab990c0cb9719f1f969e58a4abac66234d"},
+ {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:bf19725fec28452474d9887a128e98dd67eee7b7d52e932e6949c532d820dc3b"},
+ {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:d6f3d62e16c10e88d2168ba2d065aa374e3c538998ed04996cd373ff2036d64c"},
+ {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac10bbac36cd89eac19f4e51c032ba6b412b3892b685076f4acd2de18ca990aa"},
+ {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aa32aaa97d8b2ed4e54dc65d241a0da1c627454950f7d7b1f95b13985afd6c5d"},
+ {file = "yarl-1.7.2-cp36-cp36m-win32.whl", hash = "sha256:87f6e082bce21464857ba58b569370e7b547d239ca22248be68ea5d6b51464a1"},
+ {file = "yarl-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ac35ccde589ab6a1870a484ed136d49a26bcd06b6a1c6397b1967ca13ceb3913"},
+ {file = "yarl-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a467a431a0817a292121c13cbe637348b546e6ef47ca14a790aa2fa8cc93df63"},
+ {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ab0c3274d0a846840bf6c27d2c60ba771a12e4d7586bf550eefc2df0b56b3b4"},
+ {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d260d4dc495c05d6600264a197d9d6f7fc9347f21d2594926202fd08cf89a8ba"},
+ {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4dd8b01a8112809e6b636b00f487846956402834a7fd59d46d4f4267181c41"},
+ {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c1164a2eac148d85bbdd23e07dfcc930f2e633220f3eb3c3e2a25f6148c2819e"},
+ {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67e94028817defe5e705079b10a8438b8cb56e7115fa01640e9c0bb3edf67332"},
+ {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:89ccbf58e6a0ab89d487c92a490cb5660d06c3a47ca08872859672f9c511fc52"},
+ {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8cce6f9fa3df25f55521fbb5c7e4a736683148bcc0c75b21863789e5185f9185"},
+ {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:211fcd65c58bf250fb994b53bc45a442ddc9f441f6fec53e65de8cba48ded986"},
+ {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c10ea1e80a697cf7d80d1ed414b5cb8f1eec07d618f54637067ae3c0334133c4"},
+ {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:52690eb521d690ab041c3919666bea13ab9fbff80d615ec16fa81a297131276b"},
+ {file = "yarl-1.7.2-cp37-cp37m-win32.whl", hash = "sha256:695ba021a9e04418507fa930d5f0704edbce47076bdcfeeaba1c83683e5649d1"},
+ {file = "yarl-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c17965ff3706beedafd458c452bf15bac693ecd146a60a06a214614dc097a271"},
+ {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fce78593346c014d0d986b7ebc80d782b7f5e19843ca798ed62f8e3ba8728576"},
+ {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2a1ac41a6aa980db03d098a5531f13985edcb451bcd9d00670b03129922cd0d"},
+ {file = "yarl-1.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:39d5493c5ecd75c8093fa7700a2fb5c94fe28c839c8e40144b7ab7ccba6938c8"},
+ {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eb6480ef366d75b54c68164094a6a560c247370a68c02dddb11f20c4c6d3c9d"},
+ {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ba63585a89c9885f18331a55d25fe81dc2d82b71311ff8bd378fc8004202ff6"},
+ {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e39378894ee6ae9f555ae2de332d513a5763276a9265f8e7cbaeb1b1ee74623a"},
+ {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c0910c6b6c31359d2f6184828888c983d54d09d581a4a23547a35f1d0b9484b1"},
+ {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6feca8b6bfb9eef6ee057628e71e1734caf520a907b6ec0d62839e8293e945c0"},
+ {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8300401dc88cad23f5b4e4c1226f44a5aa696436a4026e456fe0e5d2f7f486e6"},
+ {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:788713c2896f426a4e166b11f4ec538b5736294ebf7d5f654ae445fd44270832"},
+ {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fd547ec596d90c8676e369dd8a581a21227fe9b4ad37d0dc7feb4ccf544c2d59"},
+ {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:737e401cd0c493f7e3dd4db72aca11cfe069531c9761b8ea474926936b3c57c8"},
+ {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf81561f2972fb895e7844882898bda1eef4b07b5b385bcd308d2098f1a767b"},
+ {file = "yarl-1.7.2-cp38-cp38-win32.whl", hash = "sha256:ede3b46cdb719c794427dcce9d8beb4abe8b9aa1e97526cc20de9bd6583ad1ef"},
+ {file = "yarl-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:cc8b7a7254c0fc3187d43d6cb54b5032d2365efd1df0cd1749c0c4df5f0ad45f"},
+ {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:580c1f15500e137a8c37053e4cbf6058944d4c114701fa59944607505c2fe3a0"},
+ {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ec1d9a0d7780416e657f1e405ba35ec1ba453a4f1511eb8b9fbab81cb8b3ce1"},
+ {file = "yarl-1.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3bf8cfe8856708ede6a73907bf0501f2dc4e104085e070a41f5d88e7faf237f3"},
+ {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be4bbb3d27a4e9aa5f3df2ab61e3701ce8fcbd3e9846dbce7c033a7e8136746"},
+ {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534b047277a9a19d858cde163aba93f3e1677d5acd92f7d10ace419d478540de"},
+ {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ddcd80d79c96eb19c354d9dca95291589c5954099836b7c8d29278a7ec0bda"},
+ {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bfcd43c65fbb339dc7086b5315750efa42a34eefad0256ba114cd8ad3896f4b"},
+ {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f64394bd7ceef1237cc604b5a89bf748c95982a84bcd3c4bbeb40f685c810794"},
+ {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044daf3012e43d4b3538562da94a88fb12a6490652dbc29fb19adfa02cf72eac"},
+ {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:368bcf400247318382cc150aaa632582d0780b28ee6053cd80268c7e72796dec"},
+ {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:bab827163113177aee910adb1f48ff7af31ee0289f434f7e22d10baf624a6dfe"},
+ {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0cba38120db72123db7c58322fa69e3c0efa933040ffb586c3a87c063ec7cae8"},
+ {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:59218fef177296451b23214c91ea3aba7858b4ae3306dde120224cfe0f7a6ee8"},
+ {file = "yarl-1.7.2-cp39-cp39-win32.whl", hash = "sha256:1edc172dcca3f11b38a9d5c7505c83c1913c0addc99cd28e993efeaafdfaa18d"},
+ {file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"},
+ {file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"},
]
diff --git a/pyproject.toml b/pyproject.toml
index 563bf4a27..2d6adb9c5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,49 +7,52 @@ license = "MIT"
[tool.poetry.dependencies]
python = "3.9.*"
-"discord.py" = {url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"}
-aio-pika = "~=6.1"
-aiodns = "~=2.0"
-aiohttp = "~=3.7"
-aioredis = "~=1.3.1"
-arrow = "~=1.0.3"
-async-rediscache = { version = "~=0.1.2", extras = ["fakeredis"] }
-beautifulsoup4 = "~=4.9"
-colorama = { version = "~=0.4.3", markers = "sys_platform == 'win32'" }
-coloredlogs = "~=14.0"
-deepdiff = "~=4.0"
-emoji = "~=0.6"
-feedparser = "~=6.0.2"
-rapidfuzz = "~=1.4"
-lxml = "~=4.4"
-markdownify = "==0.6.1"
-more_itertools = "~=8.2"
-python-dateutil = "~=2.8"
-python-frontmatter = "~=1.0.0"
-pyyaml = "~=5.1"
-regex = "==2021.4.4"
-sentry-sdk = "~=1.3"
-statsd = "~=3.3"
+
+"discord.py" = {url = "https://github.com/Rapptz/discord.py/archive/5a06fa5f3e28d2b7191722e1a84c541560008aea.zip"}
+# See https://bot-core.pythondiscord.com/ for docs.
+bot-core = {url = "https://github.com/python-discord/bot-core/archive/refs/tags/v7.0.0.zip", extras = ["async-rediscache"]}
+
+aiodns = "3.0.0"
+aiohttp = "3.8.1"
+aioredis = "1.3.1"
+arrow = "1.2.2"
+async-rediscache = { version = "0.2.0", extras = ["fakeredis"] }
+beautifulsoup4 = "4.10.0"
+colorama = { version = "0.4.4", markers = "sys_platform == 'win32'" }
+coloredlogs = "15.0.1"
+deepdiff = "5.7.0"
+emoji = "1.7.0"
+feedparser = "6.0.8"
+rapidfuzz = "2.0.7"
+lxml = "4.8.0"
+markdownify = "0.6.1"
+more_itertools = "8.12.0"
+python-dateutil = "2.8.2"
+python-frontmatter = "1.0.0"
+pyyaml = "6.0"
+regex = "2022.3.15"
+sentry-sdk = "1.5.8"
+statsd = "3.3.0"
+tldextract = "3.2.0"
[tool.poetry.dev-dependencies]
-coverage = "~=5.0"
-coveralls = "~=2.1"
-flake8 = "~=3.8"
-flake8-annotations = "~=2.0"
-flake8-bugbear = "~=20.1"
-flake8-docstrings = "~=1.4"
-flake8-string-format = "~=0.2"
-flake8-tidy-imports = "~=4.0"
-flake8-todo = "~=0.7"
-flake8-isort = "~=4.0"
-pep8-naming = "~=0.9"
-pre-commit = "~=2.1"
-taskipy = "~=1.7.0"
-pip-licenses = "~=3.5.3"
-python-dotenv = "~=0.17.1"
-pytest = "~=6.2.4"
-pytest-cov = "~=2.12.1"
-pytest-xdist = { version = "~=2.3.0", extras = ["psutil"] }
+coverage = "6.3.2"
+flake8 = "4.0.1"
+flake8-annotations = "2.8.0"
+flake8-bugbear = "22.3.23"
+flake8-docstrings = "1.6.0"
+flake8-string-format = "0.3.0"
+flake8-tidy-imports = "4.6.0"
+flake8-todo = "0.7"
+flake8-isort = "4.1.1"
+pep8-naming = "0.12.1"
+pre-commit = "2.17.0"
+taskipy = "1.10.1"
+pip-licenses = "3.5.3"
+python-dotenv = "0.20.0"
+pytest = "7.1.1"
+pytest-cov = "3.0.0"
+pytest-xdist = "2.5.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py
index 9dc46005b..a17c1fa10 100644
--- a/tests/bot/exts/backend/sync/test_base.py
+++ b/tests/bot/exts/backend/sync/test_base.py
@@ -1,7 +1,8 @@
import unittest
from unittest import mock
-from bot.api import ResponseCodeError
+from botcore.site_api import ResponseCodeError
+
from bot.exts.backend.sync._syncers import Syncer
from tests import helpers
diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py
index fdd0ab74a..87b76c6b4 100644
--- a/tests/bot/exts/backend/sync/test_cog.py
+++ b/tests/bot/exts/backend/sync/test_cog.py
@@ -2,9 +2,9 @@ import unittest
from unittest import mock
import discord
+from botcore.site_api import ResponseCodeError
from bot import constants
-from bot.api import ResponseCodeError
from bot.exts.backend import sync
from bot.exts.backend.sync._cog import Sync
from bot.exts.backend.sync._syncers import Syncer
@@ -16,11 +16,11 @@ class SyncExtensionTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the sync extension."""
@staticmethod
- def test_extension_setup():
+ async def test_extension_setup():
"""The Sync cog should be added."""
bot = helpers.MockBot()
- sync.setup(bot)
- bot.add_cog.assert_called_once()
+ await sync.setup(bot)
+ bot.add_cog.assert_awaited_once()
class SyncCogTestCase(unittest.IsolatedAsyncioTestCase):
@@ -60,22 +60,18 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase):
class SyncCogTests(SyncCogTestCase):
"""Tests for the Sync cog."""
- @mock.patch("bot.utils.scheduling.create_task")
- @mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock)
- def test_sync_cog_init(self, sync_guild, create_task):
- """Should instantiate syncers and run a sync for the guild."""
- # Reset because a Sync cog was already instantiated in setUp.
+ async def test_sync_cog_sync_on_load(self):
+ """Roles and users should be synced on cog load."""
+ guild = helpers.MockGuild()
+ self.bot.get_guild = mock.MagicMock(return_value=guild)
+
self.RoleSyncer.reset_mock()
self.UserSyncer.reset_mock()
- mock_sync_guild_coro = mock.MagicMock()
- sync_guild.return_value = mock_sync_guild_coro
-
- Sync(self.bot)
+ await self.cog.cog_load()
- sync_guild.assert_called_once_with()
- create_task.assert_called_once()
- self.assertEqual(create_task.call_args.args[0], mock_sync_guild_coro)
+ self.RoleSyncer.sync.assert_called_once_with(guild)
+ self.UserSyncer.sync.assert_called_once_with(guild)
async def test_sync_cog_sync_guild(self):
"""Roles and users should be synced only if a guild is successfully retrieved."""
@@ -87,7 +83,7 @@ class SyncCogTests(SyncCogTestCase):
self.bot.get_guild = mock.MagicMock(return_value=guild)
- await self.cog.sync_guild()
+ await self.cog.cog_load()
self.bot.wait_until_guild_available.assert_called_once()
self.bot.get_guild.assert_called_once_with(constants.Guild.id)
diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py
index 35fa0ee59..0a58126e7 100644
--- a/tests/bot/exts/backend/test_error_handler.py
+++ b/tests/bot/exts/backend/test_error_handler.py
@@ -1,9 +1,9 @@
import unittest
from unittest.mock import AsyncMock, MagicMock, call, patch
+from botcore.site_api import ResponseCodeError
from discord.ext.commands import errors
-from bot.api import ResponseCodeError
from bot.errors import InvalidInfractedUserError, LockedResourceError
from bot.exts.backend.error_handler import ErrorHandler, setup
from bot.exts.info.tags import Tags
@@ -48,6 +48,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
cog = ErrorHandler(self.bot)
cog.try_silence = AsyncMock()
cog.try_get_tag = AsyncMock()
+ cog.try_run_eval = AsyncMock(return_value=False)
for case in test_cases:
with self.subTest(try_silence_return=case["try_silence_return"], try_get_tag=case["called_try_get_tag"]):
@@ -76,6 +77,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
cog = ErrorHandler(self.bot)
cog.try_silence = AsyncMock()
cog.try_get_tag = AsyncMock()
+ cog.try_run_eval = AsyncMock()
error = errors.CommandNotFound()
@@ -83,6 +85,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
cog.try_silence.assert_not_awaited()
cog.try_get_tag.assert_not_awaited()
+ cog.try_run_eval.assert_not_awaited()
self.ctx.send.assert_not_awaited()
async def test_error_handler_user_input_error(self):
@@ -477,11 +480,11 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
@patch("bot.exts.backend.error_handler.log")
async def test_handle_api_error(self, log_mock):
- """Should `ctx.send` on HTTP error codes, `log.debug|warning` depends on code."""
+ """Should `ctx.send` on HTTP error codes, and log at correct level."""
test_cases = (
{
"error": ResponseCodeError(AsyncMock(status=400)),
- "log_level": "debug"
+ "log_level": "error"
},
{
"error": ResponseCodeError(AsyncMock(status=404)),
@@ -505,6 +508,8 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
self.ctx.send.assert_awaited_once()
if case["log_level"] == "warning":
log_mock.warning.assert_called_once()
+ elif case["log_level"] == "error":
+ log_mock.error.assert_called_once()
else:
log_mock.debug.assert_called_once()
@@ -544,11 +549,11 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
push_scope_mock.set_extra.has_calls(set_extra_calls)
-class ErrorHandlerSetupTests(unittest.TestCase):
+class ErrorHandlerSetupTests(unittest.IsolatedAsyncioTestCase):
"""Tests for `ErrorHandler` `setup` function."""
- def test_setup(self):
+ async def test_setup(self):
"""Should call `bot.add_cog` with `ErrorHandler`."""
bot = MockBot()
- setup(bot)
- bot.add_cog.assert_called_once()
+ await setup(bot)
+ bot.add_cog.assert_awaited_once()
diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py
index 0856546af..684f7abcd 100644
--- a/tests/bot/exts/events/test_code_jams.py
+++ b/tests/bot/exts/events/test_code_jams.py
@@ -160,11 +160,11 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase):
member.add_roles.assert_not_awaited()
-class CodeJamSetup(unittest.TestCase):
+class CodeJamSetup(unittest.IsolatedAsyncioTestCase):
"""Test for `setup` function of `CodeJam` cog."""
- def test_setup(self):
+ async def test_setup(self):
"""Should call `bot.add_cog`."""
bot = MockBot()
- code_jams.setup(bot)
- bot.add_cog.assert_called_once()
+ await code_jams.setup(bot)
+ bot.add_cog.assert_awaited_once()
diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py
index 06d78de9d..7282334e2 100644
--- a/tests/bot/exts/filters/test_antimalware.py
+++ b/tests/bot/exts/filters/test_antimalware.py
@@ -192,11 +192,11 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions)
-class AntiMalwareSetupTests(unittest.TestCase):
+class AntiMalwareSetupTests(unittest.IsolatedAsyncioTestCase):
"""Tests setup of the `AntiMalware` cog."""
- def test_setup(self):
+ async def test_setup(self):
"""Setup of the extension should call add_cog."""
bot = MockBot()
- antimalware.setup(bot)
- bot.add_cog.assert_called_once()
+ await antimalware.setup(bot)
+ bot.add_cog.assert_awaited_once()
diff --git a/tests/bot/exts/filters/test_filtering.py b/tests/bot/exts/filters/test_filtering.py
index 8ae59c1f1..bd26532f1 100644
--- a/tests/bot/exts/filters/test_filtering.py
+++ b/tests/bot/exts/filters/test_filtering.py
@@ -11,7 +11,7 @@ class FilteringCogTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
"""Instantiate the bot and cog."""
self.bot = MockBot()
- with patch("bot.utils.scheduling.create_task", new=lambda task, **_: task.close()):
+ with patch("botcore.utils.scheduling.create_task", new=lambda task, **_: task.close()):
self.cog = filtering.Filtering(self.bot)
@autospec(filtering.Filtering, "_get_filterlist_items", pass_mocks=False, return_value=["TOKEN"])
diff --git a/tests/bot/exts/filters/test_security.py b/tests/bot/exts/filters/test_security.py
index c0c3baa42..007b7b1eb 100644
--- a/tests/bot/exts/filters/test_security.py
+++ b/tests/bot/exts/filters/test_security.py
@@ -1,5 +1,4 @@
import unittest
-from unittest.mock import MagicMock
from discord.ext.commands import NoPrivateMessage
@@ -44,11 +43,11 @@ class SecurityCogTests(unittest.TestCase):
self.assertTrue(self.cog.check_on_guild(self.ctx))
-class SecurityCogLoadTests(unittest.TestCase):
+class SecurityCogLoadTests(unittest.IsolatedAsyncioTestCase):
"""Tests loading the `Security` cog."""
- def test_security_cog_load(self):
+ async def test_security_cog_load(self):
"""Setup of the extension should call add_cog."""
- bot = MagicMock()
- security.setup(bot)
- bot.add_cog.assert_called_once()
+ bot = MockBot()
+ await security.setup(bot)
+ bot.add_cog.assert_awaited_once()
diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py
index 4db27269a..c1f3762ac 100644
--- a/tests/bot/exts/filters/test_token_remover.py
+++ b/tests/bot/exts/filters/test_token_remover.py
@@ -395,15 +395,15 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
self.msg.channel.send.assert_not_awaited()
-class TokenRemoverExtensionTests(unittest.TestCase):
+class TokenRemoverExtensionTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the token_remover extension."""
@autospec("bot.exts.filters.token_remover", "TokenRemover")
- def test_extension_setup(self, cog):
+ async def test_extension_setup(self, cog):
"""The TokenRemover cog should be added."""
bot = MockBot()
- token_remover.setup(bot)
+ await token_remover.setup(bot)
cog.assert_called_once_with(bot)
- bot.add_cog.assert_called_once()
+ bot.add_cog.assert_awaited_once()
self.assertTrue(isinstance(bot.add_cog.call_args.args[0], TokenRemover))
diff --git a/tests/bot/exts/info/test_help.py b/tests/bot/exts/info/test_help.py
index 604c69671..2644ae40d 100644
--- a/tests/bot/exts/info/test_help.py
+++ b/tests/bot/exts/info/test_help.py
@@ -12,7 +12,6 @@ class HelpCogTests(unittest.IsolatedAsyncioTestCase):
self.bot = MockBot()
self.cog = help.Help(self.bot)
self.ctx = MockContext(bot=self.bot)
- self.bot.help_command.context = self.ctx
@autospec(help.CustomHelpCommand, "get_all_help_choices", return_value={"help"}, pass_mocks=False)
async def test_help_fuzzy_matching(self):
diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py
index 632287322..d896b7652 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -1,6 +1,7 @@
import textwrap
import unittest
import unittest.mock
+from datetime import datetime
import discord
@@ -276,6 +277,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
+ @unittest.mock.patch(
+ f"{COG_PATH}.user_messages",
+ new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count"))
+ )
async def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):
"""The embed should use the string representation of the user if they don't have a nick."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
@@ -284,8 +289,9 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
user.nick = None
user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
user.colour = 0
+ user.created_at = user.joined_at = datetime.utcnow()
- embed = await self.cog.create_user_embed(ctx, user)
+ embed = await self.cog.create_user_embed(ctx, user, False)
self.assertEqual(embed.title, "Mr. Hemlock")
@@ -293,6 +299,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
+ @unittest.mock.patch(
+ f"{COG_PATH}.user_messages",
+ new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count"))
+ )
async def test_create_user_embed_uses_nick_in_title_if_available(self):
"""The embed should use the nick if it's available."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
@@ -301,8 +311,9 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
user.nick = "Cat lover"
user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
user.colour = 0
+ user.created_at = user.joined_at = datetime.utcnow()
- embed = await self.cog.create_user_embed(ctx, user)
+ embed = await self.cog.create_user_embed(ctx, user, False)
self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)")
@@ -310,6 +321,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
+ @unittest.mock.patch(
+ f"{COG_PATH}.user_messages",
+ new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count"))
+ )
async def test_create_user_embed_ignores_everyone_role(self):
"""Created `!user` embeds should not contain mention of the @everyone-role."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
@@ -317,14 +332,19 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
# A `MockMember` has the @Everyone role by default; we add the Admins to that.
user = helpers.MockMember(roles=[admins_role], colour=100)
+ user.created_at = user.joined_at = datetime.utcnow()
- embed = await self.cog.create_user_embed(ctx, user)
+ embed = await self.cog.create_user_embed(ctx, user, False)
self.assertIn("&Admins", embed.fields[1].value)
self.assertNotIn("&Everyone", embed.fields[1].value)
@unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock)
@unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock)
+ @unittest.mock.patch(
+ f"{COG_PATH}.user_messages",
+ new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count"))
+ )
async def test_create_user_embed_expanded_information_in_moderation_channels(
self,
nomination_counts,
@@ -339,7 +359,8 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
nomination_counts.return_value = ("Nominations", "nomination info")
user = helpers.MockMember(id=314, roles=[moderators_role], colour=100)
- embed = await self.cog.create_user_embed(ctx, user)
+ user.created_at = user.joined_at = datetime.utcfromtimestamp(1)
+ embed = await self.cog.create_user_embed(ctx, user, False)
infraction_counts.assert_called_once_with(user)
nomination_counts.assert_called_once_with(user)
@@ -363,16 +384,23 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
)
@unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock)
- async def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts):
+ @unittest.mock.patch(f"{COG_PATH}.user_messages", new_callable=unittest.mock.AsyncMock)
+ async def test_create_user_embed_basic_information_outside_of_moderation_channels(
+ self,
+ user_messages,
+ infraction_counts,
+ ):
"""The embed should contain only basic infraction data outside of mod channels."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100))
moderators_role = helpers.MockRole(name='Moderators')
infraction_counts.return_value = ("Infractions", "basic infractions info")
+ user_messages.return_value = ("Messages", "user message counts")
user = helpers.MockMember(id=314, roles=[moderators_role], colour=100)
- embed = await self.cog.create_user_embed(ctx, user)
+ user.created_at = user.joined_at = datetime.utcfromtimestamp(1)
+ embed = await self.cog.create_user_embed(ctx, user, False)
infraction_counts.assert_called_once_with(user)
@@ -394,14 +422,23 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(
- "basic infractions info",
+ "user message counts",
embed.fields[2].value
)
+ self.assertEqual(
+ "basic infractions info",
+ embed.fields[3].value
+ )
+
@unittest.mock.patch(
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
+ @unittest.mock.patch(
+ f"{COG_PATH}.user_messages",
+ new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count"))
+ )
async def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):
"""The embed should be created with the colour of the top role, if a top role is available."""
ctx = helpers.MockContext()
@@ -409,7 +446,8 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
moderators_role = helpers.MockRole(name='Moderators')
user = helpers.MockMember(id=314, roles=[moderators_role], colour=100)
- embed = await self.cog.create_user_embed(ctx, user)
+ user.created_at = user.joined_at = datetime.utcnow()
+ embed = await self.cog.create_user_embed(ctx, user, False)
self.assertEqual(embed.colour, discord.Colour(100))
@@ -417,12 +455,17 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
+ @unittest.mock.patch(
+ f"{COG_PATH}.user_messages",
+ new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count"))
+ )
async def test_create_user_embed_uses_og_blurple_colour_when_user_has_no_roles(self):
"""The embed should be created with the og blurple colour if the user has no assigned roles."""
ctx = helpers.MockContext()
user = helpers.MockMember(id=217, colour=discord.Colour.default())
- embed = await self.cog.create_user_embed(ctx, user)
+ user.created_at = user.joined_at = datetime.utcnow()
+ embed = await self.cog.create_user_embed(ctx, user, False)
self.assertEqual(embed.colour, discord.Colour.og_blurple())
@@ -430,13 +473,18 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
+ @unittest.mock.patch(
+ f"{COG_PATH}.user_messages",
+ new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count"))
+ )
async def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):
"""The embed thumbnail should be set to the user's avatar in `png` format."""
ctx = helpers.MockContext()
user = helpers.MockMember(id=217, colour=0)
+ user.created_at = user.joined_at = datetime.utcnow()
user.display_avatar.url = "avatar url"
- embed = await self.cog.create_user_embed(ctx, user)
+ embed = await self.cog.create_user_embed(ctx, user, False)
self.assertEqual(embed.thumbnail.url, "avatar url")
@@ -489,7 +537,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase):
await self.cog.user_info(self.cog, ctx)
- create_embed.assert_called_once_with(ctx, self.author)
+ create_embed.assert_called_once_with(ctx, self.author, False)
ctx.send.assert_called_once()
@unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
@@ -500,7 +548,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase):
await self.cog.user_info(self.cog, ctx, self.author)
- create_embed.assert_called_once_with(ctx, self.author)
+ create_embed.assert_called_once_with(ctx, self.author, False)
ctx.send.assert_called_once()
@unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
@@ -511,7 +559,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase):
await self.cog.user_info(self.cog, ctx)
- create_embed.assert_called_once_with(ctx, self.moderator)
+ create_embed.assert_called_once_with(ctx, self.moderator, False)
ctx.send.assert_called_once()
@unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
@@ -523,5 +571,5 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase):
await self.cog.user_info(self.cog, ctx, self.target)
- create_embed.assert_called_once_with(ctx, self.target)
+ create_embed.assert_called_once_with(ctx, self.target, False)
ctx.send.assert_called_once()
diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
index 4d01e18a5..052048053 100644
--- a/tests/bot/exts/moderation/infraction/test_infractions.py
+++ b/tests/bot/exts/moderation/infraction/test_infractions.py
@@ -1,13 +1,15 @@
import inspect
import textwrap
import unittest
-from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
+from unittest.mock import ANY, AsyncMock, DEFAULT, MagicMock, Mock, patch
from discord.errors import NotFound
from bot.constants import Event
+from bot.exts.moderation.clean import Clean
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction.infractions import Infractions
+from bot.exts.moderation.infraction.management import ModManagement
from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser, autospec
@@ -62,8 +64,8 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):
@patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456)
-class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
- """Tests for voice ban related functions and commands."""
+class VoiceMuteTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for voice mute related functions and commands."""
def setUp(self):
self.bot = MockBot()
@@ -73,59 +75,59 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
self.ctx = MockContext(bot=self.bot, author=self.mod)
self.cog = Infractions(self.bot)
- async def test_permanent_voice_ban(self):
- """Should call voice ban applying function without expiry."""
- self.cog.apply_voice_ban = AsyncMock()
- self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar"))
- self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None)
+ async def test_permanent_voice_mute(self):
+ """Should call voice mute applying function without expiry."""
+ self.cog.apply_voice_mute = AsyncMock()
+ self.assertIsNone(await self.cog.voicemute(self.cog, self.ctx, self.user, reason="foobar"))
+ self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None)
- async def test_temporary_voice_ban(self):
- """Should call voice ban applying function with expiry."""
- self.cog.apply_voice_ban = AsyncMock()
- self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar"))
- self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz")
+ async def test_temporary_voice_mute(self):
+ """Should call voice mute applying function with expiry."""
+ self.cog.apply_voice_mute = AsyncMock()
+ self.assertIsNone(await self.cog.tempvoicemute(self.cog, self.ctx, self.user, "baz", reason="foobar"))
+ self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz")
- async def test_voice_unban(self):
+ async def test_voice_unmute(self):
"""Should call infraction pardoning function."""
self.cog.pardon_infraction = AsyncMock()
- self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user))
- self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user)
+ self.assertIsNone(await self.cog.unvoicemute(self.cog, self.ctx, self.user))
+ self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user)
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock):
- """Should return early when user already have Voice Ban infraction."""
+ async def test_voice_mute_user_have_active_infraction(self, get_active_infraction, post_infraction_mock):
+ """Should return early when user already have Voice Mute infraction."""
get_active_infraction.return_value = {"foo": "bar"}
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar"))
- get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban")
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar"))
+ get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_mute")
post_infraction_mock.assert_not_awaited()
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock):
+ async def test_voice_mute_infraction_post_failed(self, get_active_infraction, post_infraction_mock):
"""Should return early when posting infraction fails."""
self.cog.mod_log.ignore = MagicMock()
get_active_infraction.return_value = None
post_infraction_mock.return_value = None
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar"))
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar"))
post_infraction_mock.assert_awaited_once()
self.cog.mod_log.ignore.assert_not_called()
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock):
- """Should pass all kwargs passed to apply_voice_ban to post_infraction."""
+ async def test_voice_mute_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock):
+ """Should pass all kwargs passed to apply_voice_mute to post_infraction."""
get_active_infraction.return_value = None
# We don't want that this continue yet
post_infraction_mock.return_value = None
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23))
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar", my_kwarg=23))
post_infraction_mock.assert_awaited_once_with(
- self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23
+ self.ctx, self.user, "voice_mute", "foobar", active=True, my_kwarg=23
)
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock):
+ async def test_voice_mute_mod_log_ignore(self, get_active_infraction, post_infraction_mock):
"""Should ignore Voice Verified role removing."""
self.cog.mod_log.ignore = MagicMock()
self.cog.apply_infraction = AsyncMock()
@@ -134,11 +136,11 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
get_active_infraction.return_value = None
post_infraction_mock.return_value = {"foo": "bar"}
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar"))
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar"))
self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id)
async def action_tester(self, action, reason: str) -> None:
- """Helper method to test voice ban action."""
+ """Helper method to test voice mute action."""
self.assertTrue(inspect.iscoroutine(action))
await action
@@ -147,7 +149,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock):
+ async def test_voice_mute_apply_infraction(self, get_active_infraction, post_infraction_mock):
"""Should ignore Voice Verified role removing."""
self.cog.mod_log.ignore = MagicMock()
self.cog.apply_infraction = AsyncMock()
@@ -156,22 +158,22 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
post_infraction_mock.return_value = {"foo": "bar"}
reason = "foobar"
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, reason))
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, reason))
self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY)
await self.action_tester(self.cog.apply_infraction.call_args[0][-1], reason)
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
- async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock):
- """Should truncate reason for voice ban."""
+ async def test_voice_mute_truncate_reason(self, get_active_infraction, post_infraction_mock):
+ """Should truncate reason for voice mute."""
self.cog.mod_log.ignore = MagicMock()
self.cog.apply_infraction = AsyncMock()
get_active_infraction.return_value = None
post_infraction_mock.return_value = {"foo": "bar"}
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000))
+ self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar" * 3000))
self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY)
# Test action
@@ -180,14 +182,14 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
@autospec(_utils, "post_infraction", "get_active_infraction", return_value=None)
@autospec(Infractions, "apply_infraction")
- async def test_voice_ban_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _):
- """Should voice ban user that left the guild without throwing an error."""
+ async def test_voice_mute_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _):
+ """Should voice mute user that left the guild without throwing an error."""
infraction = {"foo": "bar"}
post_infraction_mock.return_value = {"foo": "bar"}
user = MockUser()
- await self.cog.voiceban(self.cog, self.ctx, user, reason=None)
- post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True, expires_at=None)
+ await self.cog.voicemute(self.cog, self.ctx, user, reason=None)
+ post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_mute", None, active=True, expires_at=None)
apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY)
# Test action
@@ -195,22 +197,22 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(inspect.iscoroutine(action))
await action
- async def test_voice_unban_user_not_found(self):
+ async def test_voice_unmute_user_not_found(self):
"""Should include info to return dict when user was not found from guild."""
self.guild.get_member.return_value = None
self.guild.fetch_member.side_effect = NotFound(Mock(status=404), "Not found")
- result = await self.cog.pardon_voice_ban(self.user.id, self.guild)
+ result = await self.cog.pardon_voice_mute(self.user.id, self.guild)
self.assertEqual(result, {"Info": "User was not found in the guild."})
@patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon")
@patch("bot.exts.moderation.infraction.infractions.format_user")
- async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock):
+ async def test_voice_unmute_user_found(self, format_user_mock, notify_pardon_mock):
"""Should add role back with ignoring, notify user and return log dictionary.."""
self.guild.get_member.return_value = self.user
notify_pardon_mock.return_value = True
format_user_mock.return_value = "my-user"
- result = await self.cog.pardon_voice_ban(self.user.id, self.guild)
+ result = await self.cog.pardon_voice_mute(self.user.id, self.guild)
self.assertEqual(result, {
"Member": "my-user",
"DM": "Sent"
@@ -219,15 +221,100 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
@patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon")
@patch("bot.exts.moderation.infraction.infractions.format_user")
- async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock):
+ async def test_voice_unmute_dm_fail(self, format_user_mock, notify_pardon_mock):
"""Should add role back with ignoring, notify user and return log dictionary.."""
self.guild.get_member.return_value = self.user
notify_pardon_mock.return_value = False
format_user_mock.return_value = "my-user"
- result = await self.cog.pardon_voice_ban(self.user.id, self.guild)
+ result = await self.cog.pardon_voice_mute(self.user.id, self.guild)
self.assertEqual(result, {
"Member": "my-user",
"DM": "**Failed**"
})
notify_pardon_mock.assert_awaited_once()
+
+
+class CleanBanTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for cleanban functionality."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.mod = MockMember(roles=[MockRole(id=7890123, position=10)])
+ self.user = MockMember(roles=[MockRole(id=123456, position=1)])
+ self.guild = MockGuild()
+ self.ctx = MockContext(bot=self.bot, author=self.mod)
+ self.cog = Infractions(self.bot)
+ self.clean_cog = Clean(self.bot)
+ self.management_cog = ModManagement(self.bot)
+
+ self.cog.apply_ban = AsyncMock(return_value={"id": 42})
+ self.log_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
+ self.clean_cog._clean_messages = AsyncMock(return_value=self.log_url)
+
+ def mock_get_cog(self, enable_clean, enable_manage):
+ """Mock get cog factory that allows the user to specify whether clean and manage cogs are enabled."""
+ def inner(name):
+ if name == "ModManagement":
+ return self.management_cog if enable_manage else None
+ elif name == "Clean":
+ return self.clean_cog if enable_clean else None
+ else:
+ return DEFAULT
+ return inner
+
+ async def test_cleanban_falls_back_to_native_purge_without_clean_cog(self):
+ """Should fallback to native purge if the Clean cog is not available."""
+ self.bot.get_cog.side_effect = self.mock_get_cog(False, False)
+
+ self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar"))
+ self.cog.apply_ban.assert_awaited_once_with(
+ self.ctx,
+ self.user,
+ "FooBar",
+ purge_days=1,
+ expires_at=None,
+ )
+
+ async def test_cleanban_doesnt_purge_messages_if_clean_cog_available(self):
+ """Cleanban command should use the native purge messages if the clean cog is available."""
+ self.bot.get_cog.side_effect = self.mock_get_cog(True, False)
+
+ self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar"))
+ self.cog.apply_ban.assert_awaited_once_with(
+ self.ctx,
+ self.user,
+ "FooBar",
+ expires_at=None,
+ )
+
+ @patch("bot.exts.moderation.infraction.infractions.Age")
+ async def test_cleanban_uses_clean_cog_when_available(self, mocked_age_converter):
+ """Test cleanban uses the clean cog to clean messages if it's available."""
+ self.bot.api_client.patch = AsyncMock()
+ self.bot.get_cog.side_effect = self.mock_get_cog(True, False)
+
+ mocked_age_converter.return_value.convert = AsyncMock(return_value="81M")
+ self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar"))
+
+ self.clean_cog._clean_messages.assert_awaited_once_with(
+ self.ctx,
+ users=[self.user],
+ channels="*",
+ first_limit="81M",
+ attempt_delete_invocation=False,
+ )
+
+ async def test_cleanban_edits_infraction_reason(self):
+ """Ensure cleanban edits the ban reason with a link to the clean log."""
+ self.bot.get_cog.side_effect = self.mock_get_cog(True, True)
+
+ self.management_cog.infraction_append = AsyncMock()
+ self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar"))
+
+ self.management_cog.infraction_append.assert_awaited_once_with(
+ self.ctx,
+ {"id": 42},
+ None,
+ reason=f"[Clean log]({self.log_url})"
+ )
diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py
index 72eebb254..5cf02033d 100644
--- a/tests/bot/exts/moderation/infraction/test_utils.py
+++ b/tests/bot/exts/moderation/infraction/test_utils.py
@@ -3,9 +3,9 @@ from collections import namedtuple
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, call, patch
+from botcore.site_api import ResponseCodeError
from discord import Embed, Forbidden, HTTPException, NotFound
-from bot.api import ResponseCodeError
from bot.constants import Colours, Icons
from bot.exts.moderation.infraction import _utils as utils
from tests.helpers import MockBot, MockContext, MockMember, MockUser
@@ -15,7 +15,10 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
"""Tests Moderation utils."""
def setUp(self):
- self.bot = MockBot()
+ patcher = patch("bot.instance", new=MockBot())
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
+
self.member = MockMember(id=1234)
self.user = MockUser(id=1234)
self.ctx = MockContext(bot=self.bot, author=self.member)
@@ -123,8 +126,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
else:
self.ctx.send.assert_not_awaited()
+ @unittest.skip("Current time needs to be patched so infraction duration is correct.")
@patch("bot.exts.moderation.infraction._utils.send_private_embed")
- async def test_notify_infraction(self, send_private_embed_mock):
+ async def test_send_infraction_embed(self, send_private_embed_mock):
"""
Should send an embed of a certain format as a DM and return `True` if DM successful.
@@ -132,7 +136,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
"""
test_cases = [
{
- "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"),
+ "args": (dict(id=0, type="ban", reason=None, expires_at=datetime(2020, 2, 26, 9, 20)), self.user),
"expected_output": Embed(
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
@@ -145,12 +149,12 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
).set_author(
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
- icon_url=Icons.token_removed
+ icon_url=Icons.user_ban
),
"send_result": True
},
{
- "args": (self.user, "warning", None, "Test reason."),
+ "args": (dict(id=0, type="warning", reason="Test reason.", expires_at=None), self.user),
"expected_output": Embed(
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
@@ -163,14 +167,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
).set_author(
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
- icon_url=Icons.token_removed
+ icon_url=Icons.user_warn
),
"send_result": False
},
# Note that this test case asserts that the DM that *would* get sent to the user is formatted
# correctly, even though that message is deliberately never sent.
{
- "args": (self.user, "note", None, None, Icons.defcon_denied),
+ "args": (dict(id=0, type="note", reason=None, expires_at=None), self.user),
"expected_output": Embed(
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
@@ -183,12 +187,12 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
).set_author(
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
- icon_url=Icons.defcon_denied
+ icon_url=Icons.user_warn
),
"send_result": False
},
{
- "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied),
+ "args": (dict(id=0, type="mute", reason="Test", expires_at=datetime(2020, 2, 26, 9, 20)), self.user),
"expected_output": Embed(
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
@@ -201,12 +205,12 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
).set_author(
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
- icon_url=Icons.defcon_denied
+ icon_url=Icons.user_mute
),
"send_result": False
},
{
- "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied),
+ "args": (dict(id=0, type="mute", reason="foo bar" * 4000, expires_at=None), self.user),
"expected_output": Embed(
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
@@ -219,7 +223,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
).set_author(
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
- icon_url=Icons.defcon_denied
+ icon_url=Icons.user_mute
),
"send_result": True
}
@@ -238,7 +242,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(embed.to_dict(), case["expected_output"].to_dict())
- send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed)
+ send_private_embed_mock.assert_awaited_once_with(case["args"][1], embed)
@patch("bot.exts.moderation.infraction._utils.send_private_embed")
async def test_notify_pardon(self, send_private_embed_mock):
@@ -313,7 +317,8 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase):
"type": "ban",
"user": self.member.id,
"active": False,
- "expires_at": now.isoformat()
+ "expires_at": now.isoformat(),
+ "dm_sent": False
}
self.ctx.bot.api_client.post.return_value = "foo"
@@ -350,7 +355,8 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase):
"reason": "Test reason",
"type": "mute",
"user": self.user.id,
- "active": True
+ "active": True,
+ "dm_sent": False
}
self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"]
diff --git a/tests/bot/exts/moderation/test_clean.py b/tests/bot/exts/moderation/test_clean.py
new file mode 100644
index 000000000..d7647fa48
--- /dev/null
+++ b/tests/bot/exts/moderation/test_clean.py
@@ -0,0 +1,104 @@
+import unittest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from bot.exts.moderation.clean import Clean
+from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockMessage, MockRole, MockTextChannel
+
+
+class CleanTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for clean cog functionality."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.mod = MockMember(roles=[MockRole(id=7890123, position=10)])
+ self.user = MockMember(roles=[MockRole(id=123456, position=1)])
+ self.guild = MockGuild()
+ self.ctx = MockContext(bot=self.bot, author=self.mod)
+ self.cog = Clean(self.bot)
+
+ self.log_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
+ self.cog._modlog_cleaned_messages = AsyncMock(return_value=self.log_url)
+
+ self.cog._use_cache = MagicMock(return_value=True)
+ self.cog._delete_found = AsyncMock(return_value=[42, 84])
+
+ @patch("bot.exts.moderation.clean.is_mod_channel")
+ async def test_clean_deletes_invocation_in_non_mod_channel(self, mod_channel_check):
+ """Clean command should delete the invocation message if ran in a non mod channel."""
+ mod_channel_check.return_value = False
+ self.ctx.message.delete = AsyncMock()
+
+ self.assertIsNone(await self.cog._delete_invocation(self.ctx))
+
+ self.ctx.message.delete.assert_awaited_once()
+
+ @patch("bot.exts.moderation.clean.is_mod_channel")
+ async def test_clean_doesnt_delete_invocation_in_mod_channel(self, mod_channel_check):
+ """Clean command should not delete the invocation message if ran in a mod channel."""
+ mod_channel_check.return_value = True
+ self.ctx.message.delete = AsyncMock()
+
+ self.assertIsNone(await self.cog._delete_invocation(self.ctx))
+
+ self.ctx.message.delete.assert_not_awaited()
+
+ async def test_clean_doesnt_attempt_deletion_when_attempt_delete_invocation_is_false(self):
+ """Clean command should not attempt to delete the invocation message if attempt_delete_invocation is false."""
+ self.cog._delete_invocation = AsyncMock()
+ self.bot.get_channel = MagicMock(return_value=False)
+
+ self.assertEqual(
+ await self.cog._clean_messages(
+ self.ctx,
+ None,
+ first_limit=MockMessage(),
+ attempt_delete_invocation=False,
+ ),
+ self.log_url,
+ )
+
+ self.cog._delete_invocation.assert_not_awaited()
+
+ @patch("bot.exts.moderation.clean.is_mod_channel")
+ async def test_clean_replies_with_success_message_when_ran_in_mod_channel(self, mod_channel_check):
+ """Clean command should reply to the message with a confirmation message if invoked in a mod channel."""
+ mod_channel_check.return_value = True
+ self.ctx.reply = AsyncMock()
+
+ self.assertEqual(
+ await self.cog._clean_messages(
+ self.ctx,
+ None,
+ first_limit=MockMessage(),
+ attempt_delete_invocation=False,
+ ),
+ self.log_url,
+ )
+
+ self.ctx.reply.assert_awaited_once()
+ sent_message = self.ctx.reply.await_args[0][0]
+ self.assertIn(self.log_url, sent_message)
+ self.assertIn("2 messages", sent_message)
+
+ @patch("bot.exts.moderation.clean.is_mod_channel")
+ async def test_clean_send_success_message_to_mods_when_ran_in_non_mod_channel(self, mod_channel_check):
+ """Clean command should send a confirmation message to #mods if invoked in a non-mod channel."""
+ mod_channel_check.return_value = False
+ mocked_mods = MockTextChannel(id=1234567)
+ mocked_mods.send = AsyncMock()
+ self.bot.get_channel = MagicMock(return_value=mocked_mods)
+
+ self.assertEqual(
+ await self.cog._clean_messages(
+ self.ctx,
+ None,
+ first_limit=MockMessage(),
+ attempt_delete_invocation=False,
+ ),
+ self.log_url,
+ )
+
+ mocked_mods.send.assert_awaited_once()
+ sent_message = mocked_mods.send.await_args[0][0]
+ self.assertIn(self.log_url, sent_message)
+ self.assertIn("2 messages", sent_message)
diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py
index 92ce3418a..65aecad28 100644
--- a/tests/bot/exts/moderation/test_silence.py
+++ b/tests/bot/exts/moderation/test_silence.py
@@ -114,44 +114,36 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase):
self.cog = silence.Silence(self.bot)
@autospec(silence, "SilenceNotifier", pass_mocks=False)
- async def test_async_init_got_guild(self):
+ async def test_cog_load_got_guild(self):
"""Bot got guild after it became available."""
- await self.cog._async_init()
+ await self.cog.cog_load()
self.bot.wait_until_guild_available.assert_awaited_once()
self.bot.get_guild.assert_called_once_with(Guild.id)
@autospec(silence, "SilenceNotifier", pass_mocks=False)
- async def test_async_init_got_channels(self):
+ async def test_cog_load_got_channels(self):
"""Got channels from bot."""
self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_)
- await self.cog._async_init()
+ await self.cog.cog_load()
self.assertEqual(self.cog._mod_alerts_channel.id, Channels.mod_alerts)
@autospec(silence, "SilenceNotifier")
- async def test_async_init_got_notifier(self, notifier):
+ async def test_cog_load_got_notifier(self, notifier):
"""Notifier was started with channel."""
self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_)
- await self.cog._async_init()
+ await self.cog.cog_load()
notifier.assert_called_once_with(MockTextChannel(id=Channels.mod_log))
self.assertEqual(self.cog.notifier, notifier.return_value)
@autospec(silence, "SilenceNotifier", pass_mocks=False)
- async def test_async_init_rescheduled(self):
+ async def testcog_load_rescheduled(self):
"""`_reschedule_` coroutine was awaited."""
self.cog._reschedule = mock.create_autospec(self.cog._reschedule)
- await self.cog._async_init()
+ await self.cog.cog_load()
self.cog._reschedule.assert_awaited_once_with()
- def test_cog_unload_cancelled_tasks(self):
- """The init task was cancelled."""
- self.cog._init_task = asyncio.Future()
- self.cog.cog_unload()
-
- # It's too annoying to test cancel_all since it's a done callback and wrapped in a lambda.
- self.assertTrue(self.cog._init_task.cancelled())
-
@autospec("discord.ext.commands", "has_any_role")
@mock.patch.object(silence.constants, "MODERATION_ROLES", new=(1, 2, 3))
async def test_cog_check(self, role_check):
@@ -165,7 +157,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase):
async def test_force_voice_sync(self):
"""Tests the _force_voice_sync helper function."""
- await self.cog._async_init()
+ await self.cog.cog_load()
# Create a regular member, and one member for each of the moderation roles
moderation_members = [MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES]
@@ -187,7 +179,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase):
async def test_force_voice_sync_no_channel(self):
"""Test to ensure _force_voice_sync can create its own voice channel if one is not available."""
- await self.cog._async_init()
+ await self.cog.cog_load()
channel = MockVoiceChannel(guild=MockGuild(afk_channel=None))
new_channel = MockVoiceChannel(delete=AsyncMock())
@@ -206,7 +198,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase):
async def test_voice_kick(self):
"""Test to ensure kick function can remove all members from a voice channel."""
- await self.cog._async_init()
+ await self.cog.cog_load()
# Create a regular member, and one member for each of the moderation roles
moderation_members = [MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES]
@@ -236,7 +228,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase):
async def test_kick_move_to_error(self):
"""Test to ensure move_to gets called on all members during kick, even if some fail."""
- await self.cog._async_init()
+ await self.cog.cog_load()
_, members = self.create_erroneous_members()
await self.cog._kick_voice_members(MockVoiceChannel(members=members))
@@ -245,7 +237,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase):
async def test_sync_move_to_error(self):
"""Test to ensure move_to gets called on all members during sync, even if some fail."""
- await self.cog._async_init()
+ await self.cog.cog_load()
failing_member, members = self.create_erroneous_members()
await self.cog._force_voice_sync(MockVoiceChannel(members=members))
@@ -339,7 +331,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase):
self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper)
with mock.patch.object(self.cog, "_reschedule", autospec=True):
- asyncio.run(self.cog._async_init()) # Populate instance attributes.
+ asyncio.run(self.cog.cog_load()) # Populate instance attributes.
async def test_skipped_missing_channel(self):
"""Did nothing because the channel couldn't be retrieved."""
@@ -428,7 +420,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
# Avoid unawaited coroutine warnings.
self.cog.scheduler.schedule_later.side_effect = lambda delay, task_id, coro: coro.close()
- asyncio.run(self.cog._async_init()) # Populate instance attributes.
+ asyncio.run(self.cog.cog_load()) # Populate instance attributes.
self.text_channel = MockTextChannel()
self.text_overwrite = PermissionOverwrite(
@@ -701,7 +693,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase):
overwrites_cache = mock.create_autospec(self.cog.previous_overwrites, spec_set=True)
self.cog.previous_overwrites = overwrites_cache
- asyncio.run(self.cog._async_init()) # Populate instance attributes.
+ asyncio.run(self.cog.cog_load()) # Populate instance attributes.
self.cog.scheduler.__contains__.return_value = True
overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}'
diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py
index 321a92445..b870a9945 100644
--- a/tests/bot/exts/utils/test_snekbox.py
+++ b/tests/bot/exts/utils/test_snekbox.py
@@ -2,6 +2,7 @@ import asyncio
import unittest
from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch
+from discord import AllowedMentions
from discord.ext import commands
from bot import constants
@@ -16,7 +17,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.bot = MockBot()
self.cog = Snekbox(bot=self.bot)
- async def test_post_eval(self):
+ async def test_post_job(self):
"""Post the eval code to the URLs.snekbox_eval_api endpoint."""
resp = MagicMock()
resp.json = AsyncMock(return_value="return")
@@ -25,7 +26,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
context_manager.__aenter__.return_value = resp
self.bot.http_session.post.return_value = context_manager
- self.assertEqual(await self.cog.post_eval("import random"), "return")
+ self.assertEqual(await self.cog.post_job("import random"), "return")
self.bot.http_session.post.assert_called_with(
constants.URLs.snekbox_eval_api,
json={"input": "import random"},
@@ -34,17 +35,18 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
resp.json.assert_awaited_once()
async def test_upload_output_reject_too_long(self):
- """Reject output longer than MAX_PASTE_LEN."""
- result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1))
+ """Reject output longer than MAX_PASTE_LENGTH."""
+ result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LENGTH + 1))
self.assertEqual(result, "too long to upload")
@patch("bot.exts.utils.snekbox.send_to_paste_service")
async def test_upload_output(self, mock_paste_util):
"""Upload the eval output to the URLs.paste_service.format(key="documents") endpoint."""
await self.cog.upload_output("Test output.")
- mock_paste_util.assert_called_once_with("Test output.", extension="txt")
+ mock_paste_util.assert_called_once_with("Test output.", extension="txt", max_length=snekbox.MAX_PASTE_LENGTH)
- def test_prepare_input(self):
+ async def test_codeblock_converter(self):
+ ctx = MockContext()
cases = (
('print("Hello world!")', 'print("Hello world!")', 'non-formatted'),
('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'),
@@ -60,7 +62,24 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
)
for case, expected, testname in cases:
with self.subTest(msg=f'Extract code from {testname}.'):
- self.assertEqual(self.cog.prepare_input(case), expected)
+ self.assertEqual(
+ '\n'.join(await snekbox.CodeblockConverter.convert(ctx, case)), expected
+ )
+
+ def test_prepare_timeit_input(self):
+ """Test the prepare_timeit_input codeblock detection."""
+ base_args = ('-m', 'timeit', '-s')
+ cases = (
+ (['print("Hello World")'], '', 'single block of code'),
+ (['x = 1', 'print(x)'], 'x = 1', 'two blocks of code'),
+ (['x = 1', 'print(x)', 'print("Some other code.")'], 'x = 1', 'three blocks of code')
+ )
+
+ for case, setup_code, testname in cases:
+ setup = snekbox.TIMEIT_SETUP_WRAPPER.format(setup=setup_code)
+ expected = ('\n'.join(case[1:] if setup_code else case), [*base_args, setup])
+ with self.subTest(msg=f'Test with {testname} and expected return {expected}'):
+ self.assertEqual(self.cog.prepare_timeit_input(case), expected)
def test_get_results_message(self):
"""Return error and message according to the eval result."""
@@ -71,13 +90,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
)
for stdout, returncode, expected in cases:
with self.subTest(stdout=stdout, returncode=returncode, expected=expected):
- actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode})
+ actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}, 'eval')
self.assertEqual(actual, expected)
@patch('bot.exts.utils.snekbox.Signals', side_effect=ValueError)
def test_get_results_message_invalid_signal(self, mock_signals: Mock):
self.assertEqual(
- self.cog.get_results_message({'stdout': '', 'returncode': 127}),
+ self.cog.get_results_message({'stdout': '', 'returncode': 127}, 'eval'),
('Your eval job has completed with return code 127', '')
)
@@ -85,7 +104,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
def test_get_results_message_valid_signal(self, mock_signals: Mock):
mock_signals.return_value.name = 'SIGTEST'
self.assertEqual(
- self.cog.get_results_message({'stdout': '', 'returncode': 127}),
+ self.cog.get_results_message({'stdout': '', 'returncode': 127}, 'eval'),
('Your eval job has completed with return code 127 (SIGTEST)', '')
)
@@ -155,28 +174,29 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
"""Test the eval command procedure."""
ctx = MockContext()
response = MockMessage()
- self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode')
- self.cog.send_eval = AsyncMock(return_value=response)
- self.cog.continue_eval = AsyncMock(return_value=None)
+ ctx.command = MagicMock()
- await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')
- self.cog.prepare_input.assert_called_once_with('MyAwesomeCode')
- self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode')
- self.cog.continue_eval.assert_called_once_with(ctx, response)
+ self.cog.send_job = AsyncMock(return_value=response)
+ self.cog.continue_job = AsyncMock(return_value=(None, None))
+
+ await self.cog.eval_command(self.cog, ctx=ctx, code=['MyAwesomeCode'])
+ self.cog.send_job.assert_called_once_with(ctx, 'MyAwesomeCode', args=None, job_name='eval')
+ self.cog.continue_job.assert_called_once_with(ctx, response, ctx.command)
async def test_eval_command_evaluate_twice(self):
"""Test the eval and re-eval command procedure."""
ctx = MockContext()
response = MockMessage()
- self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode')
- self.cog.send_eval = AsyncMock(return_value=response)
- self.cog.continue_eval = AsyncMock()
- self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None)
-
- await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')
- self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2'))
- self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode')
- self.cog.continue_eval.assert_called_with(ctx, response)
+ ctx.command = MagicMock()
+ self.cog.send_job = AsyncMock(return_value=response)
+ self.cog.continue_job = AsyncMock()
+ self.cog.continue_job.side_effect = (('MyAwesomeFormattedCode', None), (None, None))
+
+ await self.cog.eval_command(self.cog, ctx=ctx, code=['MyAwesomeCode'])
+ self.cog.send_job.assert_called_with(
+ ctx, 'MyAwesomeFormattedCode', args=None, job_name='eval'
+ )
+ self.cog.continue_job.assert_called_with(ctx, response, ctx.command)
async def test_eval_command_reject_two_eval_at_the_same_time(self):
"""Test if the eval command rejects an eval if the author already have a running eval."""
@@ -190,90 +210,99 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
"@LemonLemonishBeard#0042 You've already got a job running - please wait for it to finish!"
)
- async def test_eval_command_call_help(self):
- """Test if the eval command call the help command if no code is provided."""
- ctx = MockContext(command="sentinel")
- await self.cog.eval_command(self.cog, ctx=ctx, code='')
- ctx.send_help.assert_called_once_with(ctx.command)
-
- async def test_send_eval(self):
- """Test the send_eval function."""
+ async def test_send_job(self):
+ """Test the send_job function."""
ctx = MockContext()
ctx.message = MockMessage()
ctx.send = AsyncMock()
- ctx.author.mention = '@LemonLemonishBeard#0042'
+ ctx.author = MockUser(mention='@LemonLemonishBeard#0042')
- self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0})
+ self.cog.post_job = AsyncMock(return_value={'stdout': '', 'returncode': 0})
self.cog.get_results_message = MagicMock(return_value=('Return code 0', ''))
self.cog.get_status_emoji = MagicMock(return_value=':yay!:')
self.cog.format_output = AsyncMock(return_value=('[No output]', None))
mocked_filter_cog = MagicMock()
- mocked_filter_cog.filter_eval = AsyncMock(return_value=False)
+ mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=False)
self.bot.get_cog.return_value = mocked_filter_cog
- await self.cog.send_eval(ctx, 'MyAwesomeCode')
- ctx.send.assert_called_once_with(
+ await self.cog.send_job(ctx, 'MyAwesomeCode', job_name='eval')
+
+ ctx.send.assert_called_once()
+ self.assertEqual(
+ ctx.send.call_args.args[0],
'@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```\n[No output]\n```'
)
- self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
+ allowed_mentions = ctx.send.call_args.kwargs['allowed_mentions']
+ expected_allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author])
+ self.assertEqual(allowed_mentions.to_dict(), expected_allowed_mentions.to_dict())
+
+ self.cog.post_job.assert_called_once_with('MyAwesomeCode', args=None)
self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0})
- self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0})
+ self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}, 'eval')
self.cog.format_output.assert_called_once_with('')
- async def test_send_eval_with_paste_link(self):
- """Test the send_eval function with a too long output that generate a paste link."""
+ async def test_send_job_with_paste_link(self):
+ """Test the send_job function with a too long output that generate a paste link."""
ctx = MockContext()
ctx.message = MockMessage()
ctx.send = AsyncMock()
ctx.author.mention = '@LemonLemonishBeard#0042'
- self.cog.post_eval = AsyncMock(return_value={'stdout': 'Way too long beard', 'returncode': 0})
+ self.cog.post_job = AsyncMock(return_value={'stdout': 'Way too long beard', 'returncode': 0})
self.cog.get_results_message = MagicMock(return_value=('Return code 0', ''))
self.cog.get_status_emoji = MagicMock(return_value=':yay!:')
self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com'))
mocked_filter_cog = MagicMock()
- mocked_filter_cog.filter_eval = AsyncMock(return_value=False)
+ mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=False)
self.bot.get_cog.return_value = mocked_filter_cog
- await self.cog.send_eval(ctx, 'MyAwesomeCode')
- ctx.send.assert_called_once_with(
+ await self.cog.send_job(ctx, 'MyAwesomeCode', job_name='eval')
+
+ ctx.send.assert_called_once()
+ self.assertEqual(
+ ctx.send.call_args.args[0],
'@LemonLemonishBeard#0042 :yay!: Return code 0.'
'\n\n```\nWay too long beard\n```\nFull output: lookatmybeard.com'
)
- self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
+
+ self.cog.post_job.assert_called_once_with('MyAwesomeCode', args=None)
self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0})
- self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0})
+ self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}, 'eval')
self.cog.format_output.assert_called_once_with('Way too long beard')
- async def test_send_eval_with_non_zero_eval(self):
- """Test the send_eval function with a code returning a non-zero code."""
+ async def test_send_job_with_non_zero_eval(self):
+ """Test the send_job function with a code returning a non-zero code."""
ctx = MockContext()
ctx.message = MockMessage()
ctx.send = AsyncMock()
ctx.author.mention = '@LemonLemonishBeard#0042'
- self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127})
+ self.cog.post_job = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127})
self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Beard got stuck in the eval'))
self.cog.get_status_emoji = MagicMock(return_value=':nope!:')
self.cog.format_output = AsyncMock() # This function isn't called
mocked_filter_cog = MagicMock()
- mocked_filter_cog.filter_eval = AsyncMock(return_value=False)
+ mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=False)
self.bot.get_cog.return_value = mocked_filter_cog
- await self.cog.send_eval(ctx, 'MyAwesomeCode')
- ctx.send.assert_called_once_with(
+ await self.cog.send_job(ctx, 'MyAwesomeCode', job_name='eval')
+
+ ctx.send.assert_called_once()
+ self.assertEqual(
+ ctx.send.call_args.args[0],
'@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```\nBeard got stuck in the eval\n```'
)
- self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
+
+ self.cog.post_job.assert_called_once_with('MyAwesomeCode', args=None)
self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127})
- self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127})
+ self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}, 'eval')
self.cog.format_output.assert_not_called()
@patch("bot.exts.utils.snekbox.partial")
- async def test_continue_eval_does_continue(self, partial_mock):
- """Test that the continue_eval function does continue if required conditions are met."""
+ async def test_continue_job_does_continue(self, partial_mock):
+ """Test that the continue_job function does continue if required conditions are met."""
ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock()))
response = MockMessage(delete=AsyncMock())
new_msg = MockMessage()
@@ -281,30 +310,30 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
expected = "NewCode"
self.cog.get_code = create_autospec(self.cog.get_code, spec_set=True, return_value=expected)
- actual = await self.cog.continue_eval(ctx, response)
- self.cog.get_code.assert_awaited_once_with(new_msg)
- self.assertEqual(actual, expected)
+ actual = await self.cog.continue_job(ctx, response, self.cog.eval_command)
+ self.cog.get_code.assert_awaited_once_with(new_msg, ctx.command)
+ self.assertEqual(actual, (expected, None))
self.bot.wait_for.assert_has_awaits(
(
call(
'message_edit',
- check=partial_mock(snekbox.predicate_eval_message_edit, ctx),
- timeout=snekbox.REEVAL_TIMEOUT,
+ check=partial_mock(snekbox.predicate_message_edit, ctx),
+ timeout=snekbox.REDO_TIMEOUT,
),
- call('reaction_add', check=partial_mock(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10)
+ call('reaction_add', check=partial_mock(snekbox.predicate_emoji_reaction, ctx), timeout=10)
)
)
- ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)
- ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)
+ ctx.message.add_reaction.assert_called_once_with(snekbox.REDO_EMOJI)
+ ctx.message.clear_reaction.assert_called_once_with(snekbox.REDO_EMOJI)
response.delete.assert_called_once()
- async def test_continue_eval_does_not_continue(self):
+ async def test_continue_job_does_not_continue(self):
ctx = MockContext(message=MockMessage(clear_reactions=AsyncMock()))
self.bot.wait_for.side_effect = asyncio.TimeoutError
- actual = await self.cog.continue_eval(ctx, MockMessage())
- self.assertEqual(actual, None)
- ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)
+ actual = await self.cog.continue_job(ctx, MockMessage(), self.cog.eval_command)
+ self.assertEqual(actual, (None, None))
+ ctx.message.clear_reaction.assert_called_once_with(snekbox.REDO_EMOJI)
async def test_get_code(self):
"""Should return 1st arg (or None) if eval cmd in message, otherwise return full content."""
@@ -327,13 +356,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.bot.get_context.return_value = MockContext(command=command)
message = MockMessage(content=content)
- actual_code = await self.cog.get_code(message)
+ actual_code = await self.cog.get_code(message, self.cog.eval_command)
self.bot.get_context.assert_awaited_once_with(message)
self.assertEqual(actual_code, expected_code)
- def test_predicate_eval_message_edit(self):
- """Test the predicate_eval_message_edit function."""
+ def test_predicate_message_edit(self):
+ """Test the predicate_message_edit function."""
msg0 = MockMessage(id=1, content='abc')
msg1 = MockMessage(id=2, content='abcdef')
msg2 = MockMessage(id=1, content='abcdef')
@@ -346,18 +375,18 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
for ctx_msg, new_msg, expected, testname in cases:
with self.subTest(msg=f'Messages with {testname} return {expected}'):
ctx = MockContext(message=ctx_msg)
- actual = snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg)
+ actual = snekbox.predicate_message_edit(ctx, ctx_msg, new_msg)
self.assertEqual(actual, expected)
- def test_predicate_eval_emoji_reaction(self):
- """Test the predicate_eval_emoji_reaction function."""
+ def test_predicate_emoji_reaction(self):
+ """Test the predicate_emoji_reaction function."""
valid_reaction = MockReaction(message=MockMessage(id=1))
- valid_reaction.__str__.return_value = snekbox.REEVAL_EMOJI
+ valid_reaction.__str__.return_value = snekbox.REDO_EMOJI
valid_ctx = MockContext(message=MockMessage(id=1), author=MockUser(id=2))
valid_user = MockUser(id=2)
invalid_reaction_id = MockReaction(message=MockMessage(id=42))
- invalid_reaction_id.__str__.return_value = snekbox.REEVAL_EMOJI
+ invalid_reaction_id.__str__.return_value = snekbox.REDO_EMOJI
invalid_user_id = MockUser(id=42)
invalid_reaction_str = MockReaction(message=MockMessage(id=1))
invalid_reaction_str.__str__.return_value = ':longbeard:'
@@ -370,15 +399,15 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
)
for reaction, user, expected, testname in cases:
with self.subTest(msg=f'Test with {testname} and expected return {expected}'):
- actual = snekbox.predicate_eval_emoji_reaction(valid_ctx, reaction, user)
+ actual = snekbox.predicate_emoji_reaction(valid_ctx, reaction, user)
self.assertEqual(actual, expected)
-class SnekboxSetupTests(unittest.TestCase):
+class SnekboxSetupTests(unittest.IsolatedAsyncioTestCase):
"""Tests setup of the `Snekbox` cog."""
- def test_setup(self):
+ async def test_setup(self):
"""Setup of the extension should call add_cog."""
bot = MockBot()
- snekbox.setup(bot)
- bot.add_cog.assert_called_once()
+ await snekbox.setup(bot)
+ bot.add_cog.assert_awaited_once()
diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py
deleted file mode 100644
index 76bcb481d..000000000
--- a/tests/bot/test_api.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import unittest
-from unittest.mock import MagicMock
-
-from bot import api
-
-
-class APIClientTests(unittest.IsolatedAsyncioTestCase):
- """Tests for the bot's API client."""
-
- @classmethod
- def setUpClass(cls):
- """Sets up the shared fixtures for the tests."""
- cls.error_api_response = MagicMock()
- cls.error_api_response.status = 999
-
- def test_response_code_error_default_initialization(self):
- """Test the default initialization of `ResponseCodeError` without `text` or `json`"""
- error = api.ResponseCodeError(response=self.error_api_response)
-
- self.assertIs(error.status, self.error_api_response.status)
- self.assertEqual(error.response_json, {})
- self.assertEqual(error.response_text, "")
- self.assertIs(error.response, self.error_api_response)
-
- def test_response_code_error_string_representation_default_initialization(self):
- """Test the string representation of `ResponseCodeError` initialized without text or json."""
- error = api.ResponseCodeError(response=self.error_api_response)
- self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: ")
-
- def test_response_code_error_initialization_with_json(self):
- """Test the initialization of `ResponseCodeError` with json."""
- json_data = {'hello': 'world'}
- error = api.ResponseCodeError(
- response=self.error_api_response,
- response_json=json_data,
- )
- self.assertEqual(error.response_json, json_data)
- self.assertEqual(error.response_text, "")
-
- def test_response_code_error_string_representation_with_nonempty_response_json(self):
- """Test the string representation of `ResponseCodeError` initialized with json."""
- json_data = {'hello': 'world'}
- error = api.ResponseCodeError(
- response=self.error_api_response,
- response_json=json_data
- )
- self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {json_data}")
-
- def test_response_code_error_initialization_with_text(self):
- """Test the initialization of `ResponseCodeError` with text."""
- text_data = 'Lemon will eat your soul'
- error = api.ResponseCodeError(
- response=self.error_api_response,
- response_text=text_data,
- )
- self.assertEqual(error.response_text, text_data)
- self.assertEqual(error.response_json, {})
-
- def test_response_code_error_string_representation_with_nonempty_response_text(self):
- """Test the string representation of `ResponseCodeError` initialized with text."""
- text_data = 'Lemon will eat your soul'
- error = api.ResponseCodeError(
- response=self.error_api_response,
- response_text=text_data
- )
- self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {text_data}")
diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py
index 3b71022db..d0e801299 100644
--- a/tests/bot/utils/test_services.py
+++ b/tests/bot/utils/test_services.py
@@ -4,7 +4,9 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
from aiohttp import ClientConnectorError
-from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service
+from bot.utils.services import (
+ FAILED_REQUEST_ATTEMPTS, MAX_PASTE_LENGTH, PasteTooLongError, PasteUploadError, send_to_paste_service
+)
from tests.helpers import MockBot
@@ -55,23 +57,34 @@ class PasteTests(unittest.IsolatedAsyncioTestCase):
for error_json in test_cases:
with self.subTest(error_json=error_json):
response.json = AsyncMock(return_value=error_json)
- result = await send_to_paste_service("")
+ with self.assertRaises(PasteUploadError):
+ await send_to_paste_service("")
self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
- self.assertIsNone(result)
self.bot.http_session.post.reset_mock()
async def test_request_repeated_on_connection_errors(self):
"""Requests are repeated in the case of connection errors."""
self.bot.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock()))
- result = await send_to_paste_service("")
+ with self.assertRaises(PasteUploadError):
+ await send_to_paste_service("")
self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
- self.assertIsNone(result)
async def test_general_error_handled_and_request_repeated(self):
"""All `Exception`s are handled, logged and request repeated."""
self.bot.http_session.post = MagicMock(side_effect=Exception)
- result = await send_to_paste_service("")
+ with self.assertRaises(PasteUploadError):
+ await send_to_paste_service("")
self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
self.assertLogs("bot.utils", logging.ERROR)
- self.assertIsNone(result)
+
+ async def test_raises_error_on_too_long_input(self):
+ """Ensure PasteTooLongError is raised if `contents` is longer than `MAX_PASTE_LENGTH`."""
+ contents = "a" * (MAX_PASTE_LENGTH + 1)
+ with self.assertRaises(PasteTooLongError):
+ await send_to_paste_service(contents)
+
+ async def test_raises_on_too_large_max_length(self):
+ """Ensure ValueError is raised if `max_length` passed is greater than `MAX_PASTE_LENGTH`."""
+ with self.assertRaises(ValueError):
+ await send_to_paste_service("Hello World!", max_length=MAX_PASTE_LENGTH + 1)
diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py
index a3dcbfc0a..120d65176 100644
--- a/tests/bot/utils/test_time.py
+++ b/tests/bot/utils/test_time.py
@@ -13,13 +13,15 @@ class TimeTests(unittest.TestCase):
"""humanize_delta should be able to handle unknown units, and will not abort."""
# Does not abort for unknown units, as the unit name is checked
# against the attribute of the relativedelta instance.
- self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours')
+ actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='elephants', max_units=2)
+ self.assertEqual(actual, '2 days and 2 hours')
def test_humanize_delta_handle_high_units(self):
"""humanize_delta should be able to handle very high units."""
# Very high maximum units, but it only ever iterates over
# each value the relativedelta might have.
- self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'hours', 20), '2 days and 2 hours')
+ actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=20)
+ self.assertEqual(actual, '2 days and 2 hours')
def test_humanize_delta_should_normal_usage(self):
"""Testing humanize delta."""
@@ -32,7 +34,8 @@ class TimeTests(unittest.TestCase):
for delta, precision, max_units, expected in test_cases:
with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected):
- self.assertEqual(time.humanize_delta(delta, precision, max_units), expected)
+ actual = time.humanize_delta(delta, precision=precision, max_units=max_units)
+ self.assertEqual(actual, expected)
def test_humanize_delta_raises_for_invalid_max_units(self):
"""humanize_delta should raises ValueError('max_units must be positive') for invalid max_units."""
@@ -40,22 +43,11 @@ class TimeTests(unittest.TestCase):
for max_units in test_cases:
with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error:
- time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units)
- self.assertEqual(str(error.exception), 'max_units must be positive')
-
- def test_parse_rfc1123(self):
- """Testing parse_rfc1123."""
- self.assertEqual(
- time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'),
- datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
- )
-
- def test_format_infraction(self):
- """Testing format_infraction."""
- self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '<t:1576108860:f>')
+ time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=max_units)
+ self.assertEqual(str(error.exception), 'max_units must be positive.')
- def test_format_infraction_with_duration_none_expiry(self):
- """format_infraction_with_duration should work for None expiry."""
+ def test_format_with_duration_none_expiry(self):
+ """format_with_duration should work for None expiry."""
test_cases = (
(None, None, None, None),
@@ -67,10 +59,10 @@ class TimeTests(unittest.TestCase):
for expiry, date_from, max_units, expected in test_cases:
with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
- self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+ self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected)
- def test_format_infraction_with_duration_custom_units(self):
- """format_infraction_with_duration should work for custom max_units."""
+ def test_format_with_duration_custom_units(self):
+ """format_with_duration should work for custom max_units."""
test_cases = (
('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5, tzinfo=timezone.utc), 6,
'<t:32533488060:f> (11 hours, 55 minutes and 55 seconds)'),
@@ -80,10 +72,10 @@ class TimeTests(unittest.TestCase):
for expiry, date_from, max_units, expected in test_cases:
with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
- self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+ self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected)
- def test_format_infraction_with_duration_normal_usage(self):
- """format_infraction_with_duration should work for normal usage, across various durations."""
+ def test_format_with_duration_normal_usage(self):
+ """format_with_duration should work for normal usage, across various durations."""
utc = timezone.utc
test_cases = (
('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 2,
@@ -105,11 +97,11 @@ class TimeTests(unittest.TestCase):
for expiry, date_from, max_units, expected in test_cases:
with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
- self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+ self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected)
def test_until_expiration_with_duration_none_expiry(self):
- """until_expiration should work for None expiry."""
- self.assertEqual(time.until_expiration(None), None)
+ """until_expiration should return "Permanent" is expiry is None."""
+ self.assertEqual(time.until_expiration(None), "Permanent")
def test_until_expiration_with_duration_custom_units(self):
"""until_expiration should work for custom max_units."""
@@ -130,7 +122,6 @@ class TimeTests(unittest.TestCase):
('3000-12-12T00:00:00Z', '<t:32533488000:R>'),
('3000-11-23T20:09:00Z', '<t:32531918940:R>'),
('3000-11-23T20:09:00Z', '<t:32531918940:R>'),
- (None, None),
)
for expiry, expected in test_cases:
diff --git a/tests/helpers.py b/tests/helpers.py
index 9d4988d23..5f3111616 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -9,10 +9,10 @@ from typing import Iterable, Optional
import discord
from aiohttp import ClientSession
+from botcore.async_stats import AsyncStatsClient
+from botcore.site_api import APIClient
from discord.ext.commands import Context
-from bot.api import APIClient
-from bot.async_stats import AsyncStatsClient
from bot.bot import Bot
from tests._autospec import autospec # noqa: F401 other modules import it via this module
@@ -171,7 +171,7 @@ class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin):
spec_set = guild_instance
def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None:
- default_kwargs = {'id': next(self.discord_id), 'members': []}
+ default_kwargs = {'id': next(self.discord_id), 'members': [], "chunked": True}
super().__init__(**collections.ChainMap(kwargs, default_kwargs))
self.roles = [MockRole(name="@everyone", position=1, id=0)]
@@ -312,6 +312,10 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock):
command_prefix=unittest.mock.MagicMock(),
loop=_get_mock_loop(),
redis_session=unittest.mock.MagicMock(),
+ http_session=unittest.mock.MagicMock(),
+ allowed_roles=[1],
+ guild_id=1,
+ intents=discord.Intents.all(),
)
additional_spec_asyncs = ("wait_for", "redis_ready")
@@ -322,6 +326,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock):
self.api_client = MockAPIClient(loop=self.loop)
self.http_session = unittest.mock.create_autospec(spec=ClientSession, spec_set=True)
self.stats = unittest.mock.create_autospec(spec=AsyncStatsClient, spec_set=True)
+ self.add_cog = unittest.mock.AsyncMock()
# Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel`
@@ -334,6 +339,8 @@ channel_data = {
'position': 1,
'nsfw': False,
'last_message_id': 1,
+ 'bitrate': 1337,
+ 'user_limit': 25,
}
state = unittest.mock.MagicMock()
guild = unittest.mock.MagicMock()
@@ -438,6 +445,7 @@ message_data = {
}
state = unittest.mock.MagicMock()
channel = unittest.mock.MagicMock()
+channel.type = discord.ChannelType.text
message_instance = discord.Message(state=state, channel=channel, data=message_data)
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 81285e009..f3040b305 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -327,7 +327,7 @@ class MockObjectTests(unittest.TestCase):
def test_spec_propagation_of_mock_subclasses(self):
"""Test if the `spec` does not propagate to attributes of the mock object."""
test_values = (
- (helpers.MockGuild, "region"),
+ (helpers.MockGuild, "features"),
(helpers.MockRole, "mentionable"),
(helpers.MockMember, "display_name"),
(helpers.MockBot, "owner_id"),
diff --git a/tox.ini b/tox.ini
index 9472c32f9..e864b4b3e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -15,5 +15,5 @@ ignore=
# Docstring Content
D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417
# Type Annotations
- ANN002,ANN003,ANN101,ANN102,ANN204,ANN206
+ ANN002,ANN003,ANN101,ANN102,ANN204,ANN206,ANN401
per-file-ignores=tests/*:D,ANN