diff options
| author | 2021-10-17 20:35:32 +0300 | |
|---|---|---|
| committer | 2021-10-17 20:35:32 +0300 | |
| commit | 2abab682e0a785ebc4b1c5d8cca7ffe0eb6e8dc8 (patch) | |
| tree | b043f6cc5936a86dde0ee4a692434b772bb4eb94 | |
| parent | Indentation, type-hint, and documentation fixes (diff) | |
| parent | Merge pull request #1887 from python-discord/fix-guild-attr-error (diff) | |
Merge branch 'main' into cleanrework
134 files changed, 1955 insertions, 1144 deletions
| diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 84a671917..f8f2c8888 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,10 @@ on:      types:        - completed +concurrency: +  group: ${{ github.workflow }}-${{ github.ref }} +  cancel-in-progress: true +  jobs:    build:      if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b809b777..88abe6fb6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,10 @@ on:      types:        - completed +concurrency: +  group: ${{ github.workflow }}-${{ github.ref }} +  cancel-in-progress: true +  jobs:    build:      environment: production @@ -38,6 +42,6 @@ jobs:          uses: Azure/k8s-deploy@v1          with:            manifests: | -              bot/deployment.yaml +              namespaces/default/bot/deployment.yaml            images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}'            kubectl-version: 'latest' diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index e99e6d181..f2c9dfb6c 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -6,11 +6,25 @@ on:        - main    pull_request: +concurrency: +  group: ${{ github.workflow }}-${{ github.ref }} +  cancel-in-progress: true  jobs:    lint-test:      runs-on: ubuntu-latest      env: +      # List of licenses that are compatible with the MIT License and +      # can be used in our project +      ALLOWED_LICENSE: Apache Software License; +        BSD License; +        GNU Library or Lesser General Public License (LGPL); +        ISC License (ISCL); +        MIT License; +        Mozilla Public License 2.0 (MPL 2.0); +        Public Domain; +        Python Software Foundation License +        # Dummy values for required bot environment variables        BOT_API_KEY: foo        BOT_SENTRY_DSN: blah @@ -67,6 +81,15 @@ jobs:            pip install poetry            poetry install +      # Check all of our non-dev dependencies are compatible with the MIT license. +      # If you added a new dependencies that is being rejected, +      # please make sure it is compatible with the license for this project, +      # and add it to the ALLOWED_LICENSE variable +      - name: Check Dependencies License +        run: | +          pip-licenses --allow-only="$ALLOWED_LICENSE" \ +            --package $(poetry export -f requirements.txt --without-hashes | sed "s/==.*//g" | tr "\n" " ") +        # This step caches our pre-commit environment. To make sure we        # do create a new environment when our pre-commit setup changes,        # we create a cache key based on relevant factors. @@ -100,13 +123,6 @@ jobs:        - name: Run tests and generate coverage report          run: pytest -n auto --cov --disable-warnings -q -      # This step will publish the coverage reports coveralls.io and -      # print a "job" link in the output of the GitHub Action -      - name: Publish coverage report to coveralls.io -        env: -            GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -        run: coveralls -        # Prepare the Pull Request Payload artifact. If this fails, we        # we fail silently using the `continue-on-error` option. It's        # nice if this succeeds, but if it fails for any reason, it diff --git a/.github/workflows/sentry_release.yml b/.github/workflows/sentry_release.yml index f6a1e1f0e..48f5e50f4 100644 --- a/.github/workflows/sentry_release.yml +++ b/.github/workflows/sentry_release.yml @@ -5,6 +5,10 @@ on:      branches:        - main +concurrency: +  group: ${{ github.workflow }}-${{ github.ref }} +  cancel-in-progress: true +  jobs:    create_sentry_release:      runs-on: ubuntu-latest diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml index b6a71b887..4178c366d 100644 --- a/.github/workflows/status_embed.yaml +++ b/.github/workflows/status_embed.yaml @@ -9,6 +9,10 @@ on:      types:        - completed +concurrency: +  group: ${{ github.workflow }}-${{ github.ref }} +  cancel-in-progress: true +  jobs:    status_embed:      # We need to send a status embed whenever the workflow diff --git a/.gitignore b/.gitignore index f74a142f3..177345908 100644 --- a/.gitignore +++ b/.gitignore @@ -116,6 +116,7 @@ log.*  # Custom user configuration  config.yml  docker-compose.override.yml +metricity-config.toml  # xmlrunner unittest XML reports  TEST-**.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9412f07d..d8a90ac00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,6 +13,11 @@ repos:      rev: v1.5.1      hooks:        - id: python-check-blanket-noqa +  - repo: https://github.com/pycqa/isort +    rev: 5.8.0 +    hooks: +      - id: isort +        name: isort (python)    - repo: local      hooks:        - id: flake8 diff --git a/Dockerfile b/Dockerfile index 4d8592590..30bf8a361 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-slim +FROM --platform=linux/amd64 python:3.9-slim  # Set pip to have no saved cache  ENV PIP_NO_CACHE_DIR=false \ diff --git a/bot/__init__.py b/bot/__init__.py index 8f880b8e6..a1c4466f1 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -5,8 +5,7 @@ from typing import TYPE_CHECKING  from discord.ext import commands -from bot import log -from bot.command import Command +from bot import log, monkey_patches  if TYPE_CHECKING:      from bot.bot import Bot @@ -17,9 +16,11 @@ log.setup()  if os.name == "nt":      asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +monkey_patches.patch_typing() +  # 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=Command) -commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) +commands.command = partial(commands.command, cls=monkey_patches.Command) +commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=monkey_patches.Command)  instance: "Bot" = None  # Global Bot instance. diff --git a/bot/__main__.py b/bot/__main__.py index 9317563c8..0d3fce180 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,11 +1,9 @@ -import logging -  import aiohttp  import bot  from bot import constants  from bot.bot import Bot, StartupError -from bot.log import setup_sentry +from bot.log import get_logger, setup_sentry  setup_sentry() @@ -21,7 +19,7 @@ except StartupError as e:          message = "Could not connect to Redis. Is it running?"      # The exception is logged with an empty message so the actual message is visible at the bottom -    log = logging.getLogger("bot") +    log = get_logger("bot")      log.fatal("", exc_info=e.exception)      log.fatal(message) diff --git a/bot/api.py b/bot/api.py index 6ce9481f4..856f7c865 100644 --- a/bot/api.py +++ b/bot/api.py @@ -1,13 +1,14 @@  import asyncio -import logging  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 = logging.getLogger(__name__) +log = get_logger(__name__)  class ResponseCodeError(ValueError): diff --git a/bot/async_stats.py b/bot/async_stats.py index 58a80f528..2af832e5b 100644 --- a/bot/async_stats.py +++ b/bot/async_stats.py @@ -3,6 +3,8 @@ import socket  from statsd.client.base import StatsClientBase +from bot.utils import scheduling +  class AsyncStatsClient(StatsClientBase):      """An async transport method for statsd communication.""" @@ -32,7 +34,7 @@ class AsyncStatsClient(StatsClientBase):      def _send(self, data: str) -> None:          """Start an async task to send data to statsd.""" -        self._loop.create_task(self._async_send(data)) +        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.""" diff --git a/bot/bot.py b/bot/bot.py index 914da9c98..94783a466 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -1,5 +1,4 @@  import asyncio -import logging  import socket  import warnings  from collections import defaultdict @@ -14,8 +13,9 @@ from sentry_sdk import push_scope  from bot import api, constants  from bot.async_stats import AsyncStatsClient +from bot.log import get_logger -log = logging.getLogger('bot') +log = get_logger('bot')  LOCALHOST = "127.0.0.1" @@ -109,7 +109,7 @@ class Bot(commands.Bot):      def create(cls) -> "Bot":          """Create and return an instance of a Bot."""          loop = asyncio.get_event_loop() -        allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] +        allowed_roles = list({discord.Object(id_) for id_ in constants.MODERATION_ROLES})          intents = discord.Intents.all()          intents.presences = False diff --git a/bot/command.py b/bot/command.py deleted file mode 100644 index 0fb900f7b..000000000 --- a/bot/command.py +++ /dev/null @@ -1,18 +0,0 @@ -from discord.ext import commands - - -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.") diff --git a/bot/constants.py b/bot/constants.py index 4e99df7f3..f704c9e6a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -9,8 +9,6 @@ the custom configuration. Any settings left  out in the custom user configuration will stay  their default values from `config-default.yml`.  """ - -import logging  import os  from collections.abc import Mapping  from enum import Enum @@ -25,8 +23,6 @@ try:  except ModuleNotFoundError:      pass -log = logging.getLogger(__name__) -  def _env_var_constructor(loader, node):      """ @@ -104,7 +100,7 @@ def _recursive_update(original, new):  if Path("config.yml").exists(): -    log.info("Found `config.yml` file, loading constants from it.") +    print("Found `config.yml` file, loading constants from it.")      with open("config.yml", encoding="UTF-8") as f:          user_config = yaml.safe_load(f)      _recursive_update(_CONFIG_YAML, user_config) @@ -123,11 +119,10 @@ def check_required_keys(keys):                  if lookup is None:                      raise KeyError(key)          except KeyError: -            log.critical( +            raise KeyError(                  f"A configuration for `{key_path}` is required, but was not found. "                  "Please set it in `config.yml` or setup an environment variable and try again."              ) -            raise  try: @@ -186,8 +181,7 @@ class YAMLGetter(type):                  (cls.section, cls.subsection, name)                  if cls.subsection is not None else (cls.section, name)              ) -            # Only an INFO log since this can be caught through `hasattr` or `getattr`. -            log.info(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.") +            print(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.")              raise AttributeError(repr(name)) from e      def __getitem__(cls, name): @@ -472,7 +466,6 @@ class Channels(metaclass=YAMLGetter):      voice_chat_1: int      big_brother_logs: int -    talent_pool: int  class Webhooks(metaclass=YAMLGetter): @@ -483,7 +476,6 @@ class Webhooks(metaclass=YAMLGetter):      dev_log: int      duck_pond: int      incidents_archive: int -    talent_pool: int  class Roles(metaclass=YAMLGetter): diff --git a/bot/converters.py b/bot/converters.py index 546f6e8f4..5aaff4e76 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,6 +1,5 @@  from __future__ import annotations -import logging  import re  import typing as t  from datetime import datetime @@ -11,20 +10,23 @@ import dateutil.tz  import discord  from aiohttp import ClientConnectorError  from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter +from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter  from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time  from bot import exts  from bot.api import ResponseCodeError  from bot.constants import URLs +from bot.errors import InvalidInfraction  from bot.exts.info.doc import _inventory_parser +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 +  if t.TYPE_CHECKING:      from bot.exts.info.source import SourceType -log = logging.getLogger(__name__) +log = get_logger(__name__)  DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000)  RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$") @@ -69,10 +71,10 @@ 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.search(server_invite) +        invite_code = INVITE_RE.match(server_invite)          if invite_code:              response = await ctx.bot.http_session.get( -                f"{URLs.discord_invite_api}/{invite_code[1]}" +                f"{URLs.discord_invite_api}/{invite_code.group('invite')}"              )              if response.status != 404:                  invite_data = await response.json() @@ -234,11 +236,16 @@ class Inventory(Converter):      async def convert(ctx: Context, url: str) -> t.Tuple[str, _inventory_parser.InventoryDict]:          """Convert url to Intersphinx inventory URL."""          await ctx.trigger_typing() -        if (inventory := await _inventory_parser.fetch_inventory(url)) is None: -            raise BadArgument( -                f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS} attempts." -            ) -        return url, inventory +        try: +            inventory = await _inventory_parser.fetch_inventory(url) +        except _inventory_parser.InvalidHeaderError: +            raise BadArgument("Unable to parse inventory because of invalid header, check if URL is correct.") +        else: +            if inventory is None: +                raise BadArgument( +                    f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS} attempts." +                ) +            return url, inventory  class Snowflake(IDConverter): @@ -266,7 +273,7 @@ class Snowflake(IDConverter):          snowflake = int(arg)          try: -            time = snowflake_time(snowflake) +            time = snowflake_time(snowflake).replace(tzinfo=None)          except (OverflowError, OSError) as e:              # Not sure if this can ever even happen, but let's be safe.              raise BadArgument(f"{error}: {e}") @@ -409,7 +416,8 @@ class Age(DurationDelta):  class OffTopicName(Converter):      """A converter that ensures an added off-topic name is valid.""" -    ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" +    ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-<>" +    TRANSLATED_CHARACTERS = "𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-<>"      @classmethod      def translate_name(cls, name: str, *, from_unicode: bool = True) -> str: @@ -419,9 +427,9 @@ class OffTopicName(Converter):          If `from_unicode` is True, the name is translated from a discord-safe format, back to normalized text.          """          if from_unicode: -            table = str.maketrans(cls.ALLOWED_CHARACTERS, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-') +            table = str.maketrans(cls.ALLOWED_CHARACTERS, cls.TRANSLATED_CHARACTERS)          else: -            table = str.maketrans('𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-', cls.ALLOWED_CHARACTERS) +            table = str.maketrans(cls.TRANSLATED_CHARACTERS, cls.ALLOWED_CHARACTERS)          return name.translate(table) @@ -513,22 +521,51 @@ class HushDurationConverter(Converter):          return duration -class UserMentionOrID(UserConverter): +def _is_an_unambiguous_user_argument(argument: str) -> bool: +    """Check if the provided argument is a user mention, user id, or username (name#discrim).""" +    has_id_or_mention = bool(IDConverter()._get_id_match(argument) or RE_USER_MENTION.match(argument)) + +    # Check to see if the author passed a username (a discriminator exists) +    argument = argument.removeprefix('@') +    has_username = len(argument) > 5 and argument[-5] == '#' + +    return has_id_or_mention or has_username + + +AMBIGUOUS_ARGUMENT_MSG = ("`{argument}` is not a User mention, a User ID or a Username in the format" +                          " `name#discriminator`.") + + +class UnambiguousUser(UserConverter):      """ -    Converts to a `discord.User`, but only if a mention or userID is provided. +    Converts to a `discord.User`, but only if a mention, userID or a username (name#discrim) is provided. -    Unlike the default `UserConverter`, it doesn't allow conversion from a name or name#descrim. -    This is useful in cases where that lookup strategy would lead to ambiguity. +    Unlike the default `UserConverter`, it doesn't allow conversion from a name. +    This is useful in cases where that lookup strategy would lead to too much ambiguity.      """      async def convert(self, ctx: Context, argument: str) -> discord.User: -        """Convert the `arg` to a `discord.User`.""" -        match = self._get_id_match(argument) or RE_USER_MENTION.match(argument) +        """Convert the `argument` to a `discord.User`.""" +        if _is_an_unambiguous_user_argument(argument): +            return await super().convert(ctx, argument) +        else: +            raise BadArgument(AMBIGUOUS_ARGUMENT_MSG.format(argument=argument)) + -        if match is not None: +class UnambiguousMember(MemberConverter): +    """ +    Converts to a `discord.Member`, but only if a mention, userID or a username (name#discrim) is provided. + +    Unlike the default `MemberConverter`, it doesn't allow conversion from a name or nickname. +    This is useful in cases where that lookup strategy would lead to too much ambiguity. +    """ + +    async def convert(self, ctx: Context, argument: str) -> discord.Member: +        """Convert the `argument` to a `discord.Member`.""" +        if _is_an_unambiguous_user_argument(argument):              return await super().convert(ctx, argument)          else: -            raise BadArgument(f"`{argument}` is not a User mention or a User ID.") +            raise BadArgument(AMBIGUOUS_ARGUMENT_MSG.format(argument=argument))  class Infraction(Converter): @@ -547,7 +584,7 @@ class Infraction(Converter):                  "ordering": "-inserted_at"              } -            infractions = await ctx.bot.api_client.get("bot/infractions", params=params) +            infractions = await ctx.bot.api_client.get("bot/infractions/expanded", params=params)              if not infractions:                  raise BadArgument( @@ -557,7 +594,16 @@ class Infraction(Converter):                  return infractions[0]          else: -            return await ctx.bot.api_client.get(f"bot/infractions/{arg}") +            try: +                return await ctx.bot.api_client.get(f"bot/infractions/{arg}/expanded") +            except ResponseCodeError as e: +                if e.status == 404: +                    raise InvalidInfraction( +                        converter=Infraction, +                        original=e, +                        infraction_arg=arg +                    ) +                raise e  if t.TYPE_CHECKING: @@ -576,8 +622,10 @@ if t.TYPE_CHECKING:      OffTopicName = str  # noqa: F811      ISODateTime = datetime  # noqa: F811      HushDurationConverter = int  # noqa: F811 -    UserMentionOrID = discord.User  # noqa: F811 +    UnambiguousUser = discord.User  # noqa: F811 +    UnambiguousMember = discord.Member  # noqa: F811      Infraction = t.Optional[dict]  # noqa: F811  Expiry = t.Union[Duration, ISODateTime]  MemberOrUser = t.Union[discord.Member, discord.User] +UnambiguousMemberOrUser = t.Union[UnambiguousMember, UnambiguousUser] diff --git a/bot/decorators.py b/bot/decorators.py index f65ec4103..048a2a09a 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,6 +1,5 @@  import asyncio  import functools -import logging  import types  import typing as t  from contextlib import suppress @@ -10,11 +9,12 @@ from discord.ext import commands  from discord.ext.commands import Cog, Context  from bot.constants import Channels, DEBUG_MODE, RedirectOutput -from bot.utils import function +from bot.log import get_logger +from bot.utils import function, scheduling  from bot.utils.checks import ContextCheckFailure, in_whitelist_check  from bot.utils.function import command_wraps -log = logging.getLogger(__name__) +log = get_logger(__name__)  def in_whitelist( @@ -154,7 +154,7 @@ def redirect_output(              if ping_user:                  await ctx.send(f"Here's the output of your command, {ctx.author.mention}") -            asyncio.create_task(func(self, ctx, *args, **kwargs)) +            scheduling.create_task(func(self, ctx, *args, **kwargs))              message = await old_channel.send(                  f"Hey, {ctx.author.mention}, you can find the output of your command here: " diff --git a/bot/errors.py b/bot/errors.py index 2633390a8..078b645f1 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,6 +1,9 @@  from __future__ import annotations -from typing import Hashable, TYPE_CHECKING +from typing import Hashable, TYPE_CHECKING, Union + +from discord.ext.commands import ConversionError, Converter +  if TYPE_CHECKING:      from bot.converters import MemberOrUser @@ -40,6 +43,20 @@ class InvalidInfractedUserError(Exception):          super().__init__(reason) +class InvalidInfraction(ConversionError): +    """ +    Raised by the Infraction converter when trying to fetch an invalid infraction id. + +    Attributes: +        `infraction_arg` -- the value that we attempted to convert into an Infraction +    """ + +    def __init__(self, converter: Converter, original: Exception, infraction_arg: Union[int, str]): + +        self.infraction_arg = infraction_arg +        super().__init__(converter, original) + +  class BrandingMisconfiguration(RuntimeError):      """Raised by the Branding cog when a misconfigured event is encountered.""" diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index 0ba146635..9c5bdbb4e 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -1,6 +1,5 @@  import asyncio  import contextlib -import logging  import random  import typing as t  from datetime import timedelta @@ -17,8 +16,10 @@ from bot.bot import Bot  from bot.constants import Branding as BrandingConfig, Channels, Colours, Guild, MODERATION_ROLES  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 = logging.getLogger(__name__) +log = get_logger(__name__)  class AssetType(Enum): @@ -126,7 +127,7 @@ class Branding(commands.Cog):          self.bot = bot          self.repository = BrandingRepository(bot) -        self.bot.loop.create_task(self.maybe_start_daemon())  # Start depending on cache. +        scheduling.create_task(self.maybe_start_daemon(), event_loop=self.bot.loop)  # Start depending on cache.      # region: Internal logic & state management diff --git a/bot/exts/backend/branding/_repository.py b/bot/exts/backend/branding/_repository.py index 7b09d4641..d88ea67f3 100644 --- a/bot/exts/backend/branding/_repository.py +++ b/bot/exts/backend/branding/_repository.py @@ -1,4 +1,3 @@ -import logging  import typing as t  from datetime import date, datetime @@ -7,6 +6,7 @@ import frontmatter  from bot.bot import Bot  from bot.constants import Keys  from bot.errors import BrandingMisconfiguration +from bot.log import get_logger  # Base URL for requests into the branding repository.  BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents" @@ -25,7 +25,7 @@ ARBITRARY_YEAR = 2020  # Format used to parse date strings after we inject `ARBITRARY_YEAR` at the end.  DATE_FMT = "%B %d %Y"  # Ex: July 10 2020 -log = logging.getLogger(__name__) +log = get_logger(__name__)  class RemoteObject: diff --git a/bot/exts/backend/config_verifier.py b/bot/exts/backend/config_verifier.py index d72c6c22e..dc85a65a2 100644 --- a/bot/exts/backend/config_verifier.py +++ b/bot/exts/backend/config_verifier.py @@ -1,12 +1,11 @@ -import logging -  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 = logging.getLogger(__name__) +log = get_logger(__name__)  class ConfigVerifier(Cog): @@ -14,7 +13,7 @@ class ConfigVerifier(Cog):      def __init__(self, bot: Bot):          self.bot = bot -        self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) +        self.channel_verify_task = scheduling.create_task(self.verify_channels(), event_loop=self.bot.loop)      async def verify_channels(self) -> None:          """ diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 578c372c3..6ab6634a6 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,5 +1,4 @@  import difflib -import logging  import typing as t  from discord import Embed @@ -11,9 +10,10 @@ from bot.bot import Bot  from bot.constants import Colours, Icons, MODERATION_ROLES  from bot.converters import TagNameConverter  from bot.errors import InvalidInfractedUserError, LockedResourceError +from bot.log import get_logger  from bot.utils.checks import ContextCheckFailure -log = logging.getLogger(__name__) +log = get_logger(__name__)  class ErrorHandler(Cog): @@ -59,17 +59,23 @@ class ErrorHandler(Cog):              log.trace(f"Command {command} had its error already handled locally; ignoring.")              return +        debug_message = ( +            f"Command {command} invoked by {ctx.message.author} with error " +            f"{e.__class__.__name__}: {e}" +        ) +          if isinstance(e, errors.CommandNotFound) and not getattr(ctx, "invoked_from_error_handler", False):              if await self.try_silence(ctx):                  return -            # Try to look for a tag with the command's name -            await self.try_get_tag(ctx) -            return  # Exit early to avoid logging. +            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)              await self.handle_user_input_error(ctx, e)          elif isinstance(e, errors.CheckFailure): +            log.debug(debug_message)              await self.handle_check_failure(ctx, e)          elif isinstance(e, errors.CommandOnCooldown): +            log.debug(debug_message)              await ctx.send(e)          elif isinstance(e, errors.CommandInvokeError):              if isinstance(e.original, ResponseCodeError): @@ -80,22 +86,16 @@ class ErrorHandler(Cog):                  await ctx.send(f"Cannot infract that user. {e.original.reason}")              else:                  await self.handle_unexpected_error(ctx, e.original) -            return  # Exit early to avoid logging.          elif isinstance(e, errors.ConversionError):              if isinstance(e.original, ResponseCodeError):                  await self.handle_api_error(ctx, e.original)              else:                  await self.handle_unexpected_error(ctx, e.original) -            return  # Exit early to avoid logging. -        elif not isinstance(e, errors.DisabledCommand): +        elif isinstance(e, errors.DisabledCommand): +            log.debug(debug_message) +        else:              # MaxConcurrencyReached, ExtensionError              await self.handle_unexpected_error(ctx, e) -            return  # Exit early to avoid logging. - -        log.debug( -            f"Command {command} invoked by {ctx.message.author} with error " -            f"{e.__class__.__name__}: {e}" -        )      @staticmethod      def get_help_command(ctx: Context) -> t.Coroutine: @@ -188,9 +188,6 @@ 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) -        # Return to not raise the exception -        return -      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 - @@ -235,38 +232,32 @@ class ErrorHandler(Cog):          """          if isinstance(e, errors.MissingRequiredArgument):              embed = self._get_error_embed("Missing required argument", e.param.name) -            await ctx.send(embed=embed) -            await self.get_help_command(ctx)              self.bot.stats.incr("errors.missing_required_argument")          elif isinstance(e, errors.TooManyArguments):              embed = self._get_error_embed("Too many arguments", str(e)) -            await ctx.send(embed=embed) -            await self.get_help_command(ctx)              self.bot.stats.incr("errors.too_many_arguments")          elif isinstance(e, errors.BadArgument):              embed = self._get_error_embed("Bad argument", str(e)) -            await ctx.send(embed=embed) -            await self.get_help_command(ctx)              self.bot.stats.incr("errors.bad_argument")          elif isinstance(e, errors.BadUnionArgument):              embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") -            await ctx.send(embed=embed) -            await self.get_help_command(ctx)              self.bot.stats.incr("errors.bad_union_argument")          elif isinstance(e, errors.ArgumentParsingError):              embed = self._get_error_embed("Argument parsing error", str(e))              await ctx.send(embed=embed)              self.get_help_command(ctx).close()              self.bot.stats.incr("errors.argument_parsing_error") +            return          else:              embed = self._get_error_embed(                  "Input error",                  "Something about your input seems off. Check the arguments and try again."              ) -            await ctx.send(embed=embed) -            await self.get_help_command(ctx)              self.bot.stats.incr("errors.other_user_input_error") +        await ctx.send(embed=embed) +        await self.get_help_command(ctx) +      @staticmethod      async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None:          """ @@ -299,8 +290,8 @@ class ErrorHandler(Cog):      async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None:          """Send an error message in `ctx` for ResponseCodeError and log it."""          if e.status == 404: -            await ctx.send("There does not seem to be anything matching your query.")              log.debug(f"API responded with 404 for command {ctx.command}") +            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() @@ -308,12 +299,12 @@ class ErrorHandler(Cog):              await ctx.send("According to the API, your request is malformed.")              ctx.bot.stats.incr("errors.api_error_400")          elif 500 <= e.status < 600: -            await ctx.send("Sorry, there seems to be an internal issue with the API.")              log.warning(f"API responded with {e.status} for command {ctx.command}") +            await ctx.send("Sorry, there seems to be an internal issue with the API.")              ctx.bot.stats.incr("errors.api_internal_server_error")          else: -            await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).")              log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") +            await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).")              ctx.bot.stats.incr(f"errors.api_error_{e.status}")      @staticmethod diff --git a/bot/exts/backend/logging.py b/bot/exts/backend/logging.py index 823f14ea4..2d03cd580 100644 --- a/bot/exts/backend/logging.py +++ b/bot/exts/backend/logging.py @@ -1,13 +1,12 @@ -import logging -  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 = logging.getLogger(__name__) +log = get_logger(__name__)  class Logging(Cog): @@ -16,7 +15,7 @@ class Logging(Cog):      def __init__(self, bot: Bot):          self.bot = bot -        self.bot.loop.create_task(self.startup_greeting()) +        scheduling.create_task(self.startup_greeting(), event_loop=self.bot.loop)      async def startup_greeting(self) -> None:          """Announce our presence to the configured devlog channel.""" diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py index 48d2b6f02..80f5750bc 100644 --- a/bot/exts/backend/sync/_cog.py +++ b/bot/exts/backend/sync/_cog.py @@ -1,4 +1,3 @@ -import logging  from typing import Any, Dict  from discord import Member, Role, User @@ -9,8 +8,10 @@ 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 = logging.getLogger(__name__) +log = get_logger(__name__)  class Sync(Cog): @@ -18,7 +19,7 @@ class Sync(Cog):      def __init__(self, bot: Bot) -> None:          self.bot = bot -        self.bot.loop.create_task(self.sync_guild()) +        scheduling.create_task(self.sync_guild(), event_loop=self.bot.loop)      async def sync_guild(self) -> None:          """Syncs the roles/users of the guild with the database.""" diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index c9f2d2da8..45301b098 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -1,5 +1,4 @@  import abc -import logging  import typing as t  from collections import namedtuple @@ -9,8 +8,10 @@ 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 = logging.getLogger(__name__) +log = get_logger(__name__)  CHUNK_SIZE = 1000 @@ -156,7 +157,7 @@ class UserSyncer(Syncer):                  if db_user[db_field] != guild_value:                      updated_fields[db_field] = guild_value -            if guild_user := guild.get_member(db_user["id"]): +            if guild_user := await get_or_fetch_member(guild, db_user["id"]):                  seen_guild_users.add(guild_user.id)                  maybe_update("name", guild_user.name) diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py index 34ff0ad41..e8cf5f7bf 100644 --- a/bot/exts/events/code_jams/_channels.py +++ b/bot/exts/events/code_jams/_channels.py @@ -1,11 +1,11 @@ -import logging  import typing as t  import discord  from bot.constants import Categories, Channels, Roles +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  MAX_CHANNELS = 50  CATEGORY_NAME = "Code Jam" diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index e099f7dfa..b31d628d5 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -1,6 +1,5 @@  import asyncio  import csv -import logging  import typing as t  from collections import defaultdict @@ -11,9 +10,11 @@ from discord.ext import commands  from bot.bot import Bot  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 -log = logging.getLogger(__name__) +log = get_logger(__name__)  TEAM_LEADERS_COLOUR = 0x11806a  DELETION_REACTION = "\U0001f4a5" @@ -59,7 +60,7 @@ class CodeJams(commands.Cog):              reader = csv.DictReader(csv_file.splitlines())              for row in reader: -                member = ctx.guild.get_member(int(row["Team Member Discord ID"])) +                member = await get_or_fetch_member(ctx.guild, int(row["Team Member Discord ID"]))                  if member is None:                      log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}") @@ -69,8 +70,8 @@ class CodeJams(commands.Cog):              team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) -            for team_name, members in teams.items(): -                await _channels.create_team_channel(ctx.guild, team_name, members, team_leaders) +            for team_name, team_members in teams.items(): +                await _channels.create_team_channel(ctx.guild, team_name, team_members, team_leaders)              await _channels.create_team_leader_channel(ctx.guild, team_leaders)              await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 0eedeb0fb..d727f7940 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -1,4 +1,3 @@ -import logging  import typing as t  from os.path import splitext @@ -8,8 +7,9 @@ from discord.ext.commands import Cog  from bot.bot import Bot  from bot.constants import Channels, Filter, URLs  from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  PY_EMBED_DESCRIPTION = (      "It looks like you tried to attach a Python file - " @@ -63,7 +63,7 @@ class AntiMalware(Cog):              return          # Ignore code jam channels -        if hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME: +        if getattr(message.channel, "category", None) and message.channel.category.name == JAM_CATEGORY_NAME:              return          # Check if user is staff, if is, return diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 8c075fa95..37ac70508 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -1,5 +1,4 @@  import asyncio -import logging  from collections import defaultdict  from collections.abc import Mapping  from dataclasses import dataclass, field @@ -14,19 +13,17 @@ from discord.ext.commands import Cog  from bot import rules  from bot.bot import Bot  from bot.constants import ( -    AntiSpam as AntiSpamConfig, Channels, -    Colours, DEBUG_MODE, Event, Filter, -    Guild as GuildConfig, Icons, +    AntiSpam as AntiSpamConfig, Channels, Colours, DEBUG_MODE, Event, Filter, Guild as GuildConfig, Icons  )  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.message_cache import MessageCache  from bot.utils.messages import format_user, send_attachments - -log = logging.getLogger(__name__) +log = get_logger(__name__)  RULE_FUNCTION_MAPPING = {      'attachments': rules.apply_attachments, @@ -82,28 +79,34 @@ class DeletionContext:              f"**Rules:** {', '.join(rule for rule in self.rules)}\n"          ) -        # For multiple messages or those with excessive newlines, use the logs API -        if len(self.messages) > 1 or 'newlines' in self.rules: +        messages_as_list = list(self.messages.values()) +        first_message = messages_as_list[0] +        # For multiple messages and those with attachments or excessive newlines, use the logs API +        if any(( +            len(messages_as_list) > 1, +            len(first_message.attachments) > 0, +            first_message.content.count('\n') > 15 +        )):              url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments)              mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"          else:              mod_alert_message += "Message:\n" -            [message] = self.messages.values() -            content = message.clean_content +            content = first_message.clean_content              remaining_chars = 4080 - len(mod_alert_message)              if len(content) > remaining_chars: -                content = content[:remaining_chars] + "..." +                url = await modlog.upload_log([first_message], actor_id, self.attachments) +                log_site_msg = f"The full message can be found [here]({url})" +                content = content[:remaining_chars - (3 + len(log_site_msg))] + "..." -            mod_alert_message += f"{content}" +            mod_alert_message += content -        *_, last_message = self.messages.values()          await modlog.send_log_message(              icon_url=Icons.filtering,              colour=Colour(Colours.soft_red),              title="Spam detected!",              text=mod_alert_message, -            thumbnail=last_message.author.avatar_url_as(static_format="png"), +            thumbnail=first_message.author.display_avatar.url,              channel_id=Channels.mod_alerts,              ping_everyone=AntiSpamConfig.ping_everyone          ) @@ -129,7 +132,11 @@ class AntiSpam(Cog):          self.max_interval = max_interval_config['interval']          self.cache = MessageCache(AntiSpamConfig.cache_size, newest_first=True) -        self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error") +        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: @@ -162,7 +169,7 @@ class AntiSpam(Cog):              not message.guild              or message.guild.id != GuildConfig.id              or message.author.bot -            or (hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME) +            or (getattr(message.channel, "category", None) and message.channel.category.name == JAM_CATEGORY_NAME)              or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE)              or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE)          ): @@ -171,7 +178,9 @@ class AntiSpam(Cog):          self.cache.append(message)          earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval) -        relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.cache)) +        relevant_messages = list( +            takewhile(lambda msg: msg.created_at.replace(tzinfo=None) > earliest_relevant_at, self.cache) +        )          for rule_name in AntiSpamConfig.rules:              rule_config = AntiSpamConfig.rules[rule_name] @@ -180,7 +189,9 @@ class AntiSpam(Cog):              # Create a list of messages that were sent in the interval that the rule cares about.              latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval'])              messages_for_rule = list( -                takewhile(lambda msg: msg.created_at > latest_interesting_stamp, relevant_messages) +                takewhile( +                    lambda msg: msg.created_at.replace(tzinfo=None) > latest_interesting_stamp, relevant_messages +                )              )              result = await rule_function(message, messages_for_rule, rule_config) @@ -250,7 +261,20 @@ class AntiSpam(Cog):                  for message in messages:                      channel_messages[message.channel].append(message)                  for channel, messages in channel_messages.items(): -                    await channel.delete_messages(messages) +                    try: +                        await channel.delete_messages(messages) +                    except NotFound: +                        # In the rare case where we found messages matching the +                        # spam filter across multiple channels, it is possible +                        # that a single channel will only contain a single message +                        # to delete. If that should be the case, discord.py will +                        # use the "delete single message" endpoint instead of the +                        # bulk delete endpoint, and the single message deletion +                        # endpoint will complain if you give it that does not exist. +                        # As this means that we have no other message to delete in +                        # this channel (and message deletes work per-channel), +                        # we can just log an exception and carry on with business. +                        log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.")              # Otherwise, the bulk delete endpoint will throw up.              # Delete the message directly instead. diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index 232c1e48b..4b5200684 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -1,4 +1,3 @@ -import logging  from typing import Optional  from discord import Colour, Embed @@ -8,9 +7,11 @@ from bot import constants  from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.converters import ValidDiscordServerInvite, ValidFilterListType +from bot.log import get_logger  from bot.pagination import LinePaginator +from bot.utils import scheduling -log = logging.getLogger(__name__) +log = get_logger(__name__)  class FilterLists(Cog): @@ -27,7 +28,7 @@ class FilterLists(Cog):      def __init__(self, bot: Bot) -> None:          self.bot = bot -        self.bot.loop.create_task(self._amend_docstrings()) +        scheduling.create_task(self._amend_docstrings(), event_loop=self.bot.loop)      async def _amend_docstrings(self) -> None:          """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 10cc7885d..a151db1f0 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -1,5 +1,4 @@  import asyncio -import logging  import re  from datetime import datetime, timedelta  from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union @@ -15,17 +14,15 @@ 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 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 -from bot.utils.scheduling import Scheduler -log = logging.getLogger(__name__) +log = get_logger(__name__)  # Regular expressions  CODE_BLOCK_RE = re.compile( @@ -64,7 +61,7 @@ class Filtering(Cog):      def __init__(self, bot: Bot):          self.bot = bot -        self.scheduler = Scheduler(self.__class__.__name__) +        self.scheduler = scheduling.Scheduler(self.__class__.__name__)          self.name_lock = asyncio.Lock()          staff_mistake_str = "If you believe this was a mistake, please let staff know!" @@ -133,7 +130,7 @@ class Filtering(Cog):              },          } -        self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) +        scheduling.create_task(self.reschedule_offensive_msg_deletion(), event_loop=self.bot.loop)      def cog_unload(self) -> None:          """Cancel scheduled tasks.""" @@ -226,7 +223,7 @@ class Filtering(Cog):                  title="Username filtering alert",                  text=log_string,                  channel_id=Channels.mod_alerts, -                thumbnail=member.avatar_url +                thumbnail=member.display_avatar.url              )              # Update time when alert sent @@ -386,7 +383,7 @@ class Filtering(Cog):              colour=Colour(Colours.soft_red),              title=f"{_filter['type'].title()} triggered!",              text=message, -            thumbnail=msg.author.avatar_url_as(static_format="png"), +            thumbnail=msg.author.display_avatar.url,              channel_id=Channels.mod_alerts,              ping_everyone=ping_everyone,              additional_embeds=stats.additional_embeds, @@ -478,16 +475,12 @@ class Filtering(Cog):          Second return value is a reason of URL blacklisting (can be None).          """          text = self.clean_input(text) -        if not URL_RE.search(text): -            return False, None -        text = text.lower()          domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) - -        for url in domain_blacklist: -            if url.lower() in text: -                return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"] - +        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"]          return False, None      @staticmethod @@ -514,7 +507,7 @@ class Filtering(Cog):          # discord\.gg/gdudes-pony-farm          text = text.replace("\\", "") -        invites = INVITE_RE.findall(text) +        invites = [m.group("invite") for m in INVITE_RE.finditer(text)]          invite_data = dict()          for invite in invites:              if invite in invite_data: diff --git a/bot/exts/filters/security.py b/bot/exts/filters/security.py index c680c5e27..fe3918423 100644 --- a/bot/exts/filters/security.py +++ b/bot/exts/filters/security.py @@ -1,10 +1,9 @@ -import logging -  from discord.ext.commands import Cog, Context, NoPrivateMessage  from bot.bot import Bot +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  class Security(Cog): diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 93f1f3c33..520283ba3 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -1,6 +1,5 @@  import base64  import binascii -import logging  import re  import typing as t @@ -11,9 +10,11 @@ from bot import utils  from bot.bot import Bot  from bot.constants import Channels, Colours, Event, Icons  from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger +from bot.utils.members import get_or_fetch_member  from bot.utils.messages import format_user -log = logging.getLogger(__name__) +log = get_logger(__name__)  LOG_MESSAGE = (      "Censored a seemingly valid token sent by {author} in {channel}, " @@ -99,7 +100,7 @@ class TokenRemover(Cog):          await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))          log_message = self.format_log_message(msg, found_token) -        userid_message, mention_everyone = self.format_userid_log_message(msg, found_token) +        userid_message, mention_everyone = await self.format_userid_log_message(msg, found_token)          log.debug(log_message)          # Send pretty mod log embed to mod-alerts @@ -108,7 +109,7 @@ class TokenRemover(Cog):              colour=Colour(Colours.soft_red),              title="Token removed!",              text=log_message + "\n" + userid_message, -            thumbnail=msg.author.avatar_url_as(static_format="png"), +            thumbnail=msg.author.display_avatar.url,              channel_id=Channels.mod_alerts,              ping_everyone=mention_everyone,          ) @@ -116,7 +117,7 @@ class TokenRemover(Cog):          self.bot.stats.incr("tokens.removed_tokens")      @classmethod -    def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]: +    async def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]:          """          Format the portion of the log message that includes details about the detected user ID. @@ -128,7 +129,7 @@ class TokenRemover(Cog):          Returns a tuple of (log_message, mention_everyone)          """          user_id = cls.extract_user_id(token.user_id) -        user = msg.guild.get_member(user_id) +        user = await get_or_fetch_member(msg.guild, user_id)          if user:              return KNOWN_USER_LOG_MESSAGE.format( diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index 25e267426..96334317c 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -1,4 +1,3 @@ -import logging  import re  from discord import Colour, Message, NotFound @@ -7,6 +6,7 @@ from discord.ext.commands import Cog  from bot.bot import Bot  from bot.constants import Channels, Colours, Event, Icons  from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger  from bot.utils.messages import format_user  WEBHOOK_URL_RE = re.compile( @@ -21,7 +21,7 @@ ALERT_MESSAGE_TEMPLATE = (      "mistake, please let us know."  ) -log = logging.getLogger(__name__) +log = get_logger(__name__)  class WebhookRemover(Cog): @@ -63,7 +63,7 @@ class WebhookRemover(Cog):              colour=Colour(Colours.soft_red),              title="Discord webhook URL removed!",              text=message, -            thumbnail=msg.author.avatar_url_as(static_format="png"), +            thumbnail=msg.author.display_avatar.url,              channel_id=Channels.mod_alerts          ) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 7f7e4585c..c51656343 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -1,5 +1,4 @@  import asyncio -import logging  from typing import Union  import discord @@ -9,11 +8,13 @@ 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 -log = logging.getLogger(__name__) +log = get_logger(__name__)  class DuckPond(Cog): @@ -24,7 +25,7 @@ class DuckPond(Cog):          self.webhook_id = constants.Webhooks.duck_pond          self.webhook = None          self.ducked_messages = [] -        self.bot.loop.create_task(self.fetch_webhook()) +        scheduling.create_task(self.fetch_webhook(), event_loop=self.bot.loop)          self.relay_lock = None      async def fetch_webhook(self) -> None: @@ -93,7 +94,7 @@ class DuckPond(Cog):                  webhook=self.webhook,                  content=message.clean_content,                  username=message.author.display_name, -                avatar_url=message.author.avatar_url +                avatar_url=message.author.display_avatar.url              )          if message.attachments: @@ -108,7 +109,7 @@ class DuckPond(Cog):                      webhook=self.webhook,                      embed=e,                      username=message.author.display_name, -                    avatar_url=message.author.avatar_url +                    avatar_url=message.author.display_avatar.url                  )              except discord.HTTPException:                  log.exception("Failed to send an attachment to the webhook") diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py index 845b8175c..427667c66 100644 --- a/bot/exts/fun/off_topic_names.py +++ b/bot/exts/fun/off_topic_names.py @@ -1,5 +1,4 @@  import difflib -import logging  from datetime import datetime, timedelta  from discord import Colour, Embed @@ -10,10 +9,12 @@ from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import 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 = logging.getLogger(__name__) +log = get_logger(__name__)  async def update_names(bot: Bot) -> None: @@ -50,7 +51,7 @@ class OffTopicNames(Cog):          self.bot = bot          self.updater_task = None -        self.bot.loop.create_task(self.init_offtopic_updater()) +        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.""" @@ -62,7 +63,7 @@ class OffTopicNames(Cog):          await self.bot.wait_until_guild_available()          if self.updater_task is None:              coro = update_names(self.bot) -            self.updater_task = self.bot.loop.create_task(coro) +            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) diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py index 781f40449..beba18aa6 100644 --- a/bot/exts/help_channels/__init__.py +++ b/bot/exts/help_channels/__init__.py @@ -1,10 +1,9 @@ -import logging -  from bot import constants  from bot.bot import Bot  from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  def validate_config() -> None: diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 0846b28c8..e43c1e789 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -1,4 +1,3 @@ -import logging  import typing as t  from datetime import timedelta  from enum import Enum @@ -10,9 +9,10 @@ from arrow import Arrow  import bot  from bot import constants  from bot.exts.help_channels import _caches, _message -from bot.utils.channel import try_get_channel +from bot.log import get_logger +from bot.utils.channel import get_or_fetch_channel -log = logging.getLogger(__name__) +log = get_logger(__name__)  MAX_CHANNELS_PER_CATEGORY = 50  EXCLUDED_CHANNELS = (constants.Channels.cooldown,) @@ -133,7 +133,7 @@ async def move_to_bottom(channel: discord.TextChannel, category_id: int, **optio      options should be avoided, as it may interfere with the category move we perform.      """      # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. -    category = await try_get_channel(category_id) +    category = await get_or_fetch_channel(category_id)      payload = [{"id": c.id, "position": c.position} for c in category.channels] diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index cfc9cf477..498305b47 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -1,5 +1,4 @@  import asyncio -import logging  import random  import typing as t  from datetime import timedelta @@ -14,9 +13,10 @@ from bot import constants  from bot.bot import Bot  from bot.constants import Channels, RedirectOutput  from bot.exts.help_channels import _caches, _channel, _message, _name, _stats -from bot.utils import channel as channel_utils, lock, scheduling +from bot.log import get_logger +from bot.utils import channel as channel_utils, lock, members, scheduling -log = logging.getLogger(__name__) +log = get_logger(__name__)  NAMESPACE = "help"  HELP_CHANNEL_TOPIC = """ @@ -82,7 +82,7 @@ class HelpChannels(commands.Cog):          # Asyncio stuff          self.queue_tasks: t.List[asyncio.Task] = [] -        self.init_task = self.bot.loop.create_task(self.init_cog()) +        self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop)      def cog_unload(self) -> None:          """Cancel the init task and scheduled tasks when the cog unloads.""" @@ -278,13 +278,13 @@ class HelpChannels(commands.Cog):          log.trace("Getting the CategoryChannel objects for the help categories.")          try: -            self.available_category = await channel_utils.try_get_channel( +            self.available_category = await channel_utils.get_or_fetch_channel(                  constants.Categories.help_available              ) -            self.in_use_category = await channel_utils.try_get_channel( +            self.in_use_category = await channel_utils.get_or_fetch_channel(                  constants.Categories.help_in_use              ) -            self.dormant_category = await channel_utils.try_get_channel( +            self.dormant_category = await channel_utils.get_or_fetch_channel(                  constants.Categories.help_dormant              )          except discord.HTTPException: @@ -434,7 +434,7 @@ class HelpChannels(commands.Cog):          await _caches.claimants.delete(channel.id)          await _caches.session_participants.delete(channel.id) -        claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id) +        claimant = await members.get_or_fetch_member(self.bot.get_guild(constants.Guild.id), 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: @@ -507,7 +507,7 @@ class HelpChannels(commands.Cog):          """Wait for a dormant channel to become available in the queue and return it."""          log.trace("Waiting for a dormant channel.") -        task = asyncio.create_task(self.channel_queue.get()) +        task = scheduling.create_task(self.channel_queue.get())          self.queue_tasks.append(task)          channel = await task diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 077b20b47..a52c67570 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -1,4 +1,3 @@ -import logging  import textwrap  import typing as t @@ -9,8 +8,9 @@ from arrow import Arrow  import bot  from bot import constants  from bot.exts.help_channels import _caches +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" diff --git a/bot/exts/help_channels/_name.py b/bot/exts/help_channels/_name.py index 061f855ae..a9d9b2df1 100644 --- a/bot/exts/help_channels/_name.py +++ b/bot/exts/help_channels/_name.py @@ -1,5 +1,4 @@  import json -import logging  import typing as t  from collections import deque  from pathlib import Path @@ -8,8 +7,9 @@ import discord  from bot import constants  from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY, get_category_channels +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  def create_name_queue(*categories: discord.CategoryChannel) -> deque: diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py index eb34e75e1..4698c26de 100644 --- a/bot/exts/help_channels/_stats.py +++ b/bot/exts/help_channels/_stats.py @@ -1,12 +1,11 @@ -import logging -  from more_itertools import ilen  import bot  from bot import constants  from bot.exts.help_channels import _caches, _channel +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  def report_counts() -> None: diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 4a90a0668..07b1b8a2d 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -10,9 +10,10 @@ from discord.ext.commands import Cog  from bot.bot import Bot  from bot.constants import Channels +from bot.log import get_logger  from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__)  GITHUB_RE = re.compile(      r'https://github\.com/(?P<repo>[a-zA-Z0-9-]+/[\w.-]+)/blob/' diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py index 9a0705d2b..a859d8cef 100644 --- a/bot/exts/info/codeblock/_cog.py +++ b/bot/exts/info/codeblock/_cog.py @@ -1,4 +1,3 @@ -import logging  import time  from typing import Optional @@ -11,11 +10,12 @@ from bot.bot import Bot  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.utils import has_lines +from bot.log import get_logger +from bot.utils import has_lines, scheduling  from bot.utils.channel import is_help_channel  from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__)  class CodeBlockCog(Cog, name="Code Block"): @@ -114,7 +114,7 @@ class CodeBlockCog(Cog, name="Code Block"):          bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed)          self.codeblock_message_ids[message.id] = bot_message.id -        self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,))) +        scheduling.create_task(wait_for_deletion(bot_message, (message.author.id,)), event_loop=self.bot.loop)          # Increase amount of codeblock correction in stats          self.bot.stats.incr("codeblock_corrections") diff --git a/bot/exts/info/codeblock/_instructions.py b/bot/exts/info/codeblock/_instructions.py index dadb5e1ef..8fcadeec2 100644 --- a/bot/exts/info/codeblock/_instructions.py +++ b/bot/exts/info/codeblock/_instructions.py @@ -1,11 +1,11 @@  """This module generates and formats instructional messages about fixing Markdown code blocks.""" -import logging  from typing import Optional  from bot.exts.info.codeblock import _parsing +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  _EXAMPLE_PY = "{lang}\nprint('Hello, world!')"  # Make sure to escape any Markdown symbols here.  _EXAMPLE_CODE_BLOCKS = ( diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index 73fd11b94..3c193d6c5 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -1,15 +1,15 @@  """This module provides functions for parsing Markdown code blocks."""  import ast -import logging  import re  import textwrap  from typing import NamedTuple, Optional, Sequence  from bot import constants +from bot.log import get_logger  from bot.utils import has_lines -log = logging.getLogger(__name__) +log = get_logger(__name__)  BACKTICK = "`"  PY_LANG_CODES = ("python-repl", "python", "pycon", "py")  # Order is important; "py" is last cause it's a subset. diff --git a/bot/exts/info/doc/__init__.py b/bot/exts/info/doc/__init__.py index 38a8975c0..facdf4d0b 100644 --- a/bot/exts/info/doc/__init__.py +++ b/bot/exts/info/doc/__init__.py @@ -1,4 +1,5 @@  from bot.bot import Bot +  from ._redis_cache import DocRedisCache  MAX_SIGNATURE_AMOUNT = 3 diff --git a/bot/exts/info/doc/_batch_parser.py b/bot/exts/info/doc/_batch_parser.py index 369bb462c..c27f28eac 100644 --- a/bot/exts/info/doc/_batch_parser.py +++ b/bot/exts/info/doc/_batch_parser.py @@ -2,7 +2,6 @@ from __future__ import annotations  import asyncio  import collections -import logging  from collections import defaultdict  from contextlib import suppress  from operator import attrgetter @@ -13,20 +12,26 @@ 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 +from ._redis_cache import StaleItemCounter -log = logging.getLogger(__name__) +log = get_logger(__name__)  class StaleInventoryNotifier:      """Handle sending notifications about stale inventories through `DocItem`s to dev log.""" +    symbol_counter = StaleItemCounter() +      def __init__(self): -        self._init_task = bot.instance.loop.create_task( +        self._init_task = scheduling.create_task(              self._init_channel(), -            name="StaleInventoryNotifier channel init" +            name="StaleInventoryNotifier channel init", +            event_loop=bot.instance.loop,          )          self._warned_urls = set() @@ -38,13 +43,16 @@ class StaleInventoryNotifier:      async def send_warning(self, doc_item: _cog.DocItem) -> None:          """Send a warning to dev log if one wasn't already sent for `item`'s url."""          if doc_item.url not in self._warned_urls: -            self._warned_urls.add(doc_item.url) -            await self._init_task -            embed = discord.Embed( -                description=f"Doc item `{doc_item.symbol_id=}` present in loaded documentation inventories " -                            f"not found on [site]({doc_item.url}), inventories may need to be refreshed." -            ) -            await self._dev_log.send(embed=embed) +            # Only warn if the item got less than 3 warnings +            # or if it has been more than 3 weeks since the last warning +            if await self.symbol_counter.increment_for(doc_item) < 3: +                self._warned_urls.add(doc_item.url) +                await self._init_task +                embed = discord.Embed( +                    description=f"Doc item `{doc_item.symbol_id=}` present in loaded documentation inventories " +                                f"not found on [site]({doc_item.url}), inventories may need to be refreshed." +                ) +                await self._dev_log.send(embed=embed)  class QueueItem(NamedTuple): @@ -101,7 +109,7 @@ class BatchParser:          if doc_item not in self._item_futures and doc_item not in self._queue:              self._item_futures[doc_item].user_requested = True -            async with bot.instance.http_session.get(doc_item.url) as response: +            async with bot.instance.http_session.get(doc_item.url, raise_for_status=True) as response:                  soup = await bot.instance.loop.run_in_executor(                      None,                      BeautifulSoup, diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index fb9b2584a..ebf5f5932 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -1,7 +1,6 @@  from __future__ import annotations  import asyncio -import logging  import sys  import textwrap  from collections import defaultdict @@ -13,17 +12,21 @@ import aiohttp  import discord  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.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 InventoryDict, fetch_inventory +from ._inventory_parser import InvalidHeaderError, InventoryDict, fetch_inventory -log = logging.getLogger(__name__) +log = get_logger(__name__)  # symbols with a group contained here will get the group prefixed on duplicates  FORCE_PREFIX_GROUPS = ( @@ -75,9 +78,10 @@ class DocCog(commands.Cog):          self.refresh_event.set()          self.symbol_get_event = SharedEvent() -        self.init_refresh_task = self.bot.loop.create_task( +        self.init_refresh_task = scheduling.create_task(              self.init_refresh_inventory(), -            name="Doc inventory init" +            name="Doc inventory init", +            event_loop=self.bot.loop,          )      @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) @@ -135,7 +139,12 @@ class DocCog(commands.Cog):          The first attempt is rescheduled to execute in `FETCH_RESCHEDULE_DELAY.first` minutes, the subsequent attempts          in `FETCH_RESCHEDULE_DELAY.repeated` minutes.          """ -        package = await fetch_inventory(inventory_url) +        try: +            package = await fetch_inventory(inventory_url) +        except InvalidHeaderError as e: +            # Do not reschedule if the header is invalid, as the request went through but the contents are invalid. +            log.warning(f"Invalid inventory header at {inventory_url}. Reason: {e}") +            return          if not package:              if api_package_name in self.inventory_scheduler: @@ -150,6 +159,8 @@ class DocCog(commands.Cog):                  self.update_or_reschedule_inventory(api_package_name, base_url, inventory_url),              )          else: +            if not base_url: +                base_url = self.base_url_from_inventory_url(inventory_url)              self.update_single(api_package_name, base_url, package)      def ensure_unique_symbol_name(self, package_name: str, group_name: str, symbol_name: str) -> str: @@ -352,6 +363,11 @@ class DocCog(commands.Cog):                  msg = await ctx.send(embed=doc_embed)                  await wait_for_deletion(msg, (ctx.author.id,)) +    @staticmethod +    def base_url_from_inventory_url(inventory_url: str) -> str: +        """Get a base url from the url to an objects inventory by removing the last path segment.""" +        return inventory_url.removesuffix("/").rsplit("/", maxsplit=1)[0] + "/" +      @docs_group.command(name="setdoc", aliases=("s",))      @commands.has_any_role(*MODERATION_ROLES)      @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) @@ -359,21 +375,21 @@ class DocCog(commands.Cog):          self,          ctx: commands.Context,          package_name: PackageName, -        base_url: ValidURL,          inventory: Inventory, +        base_url: ValidURL = "",      ) -> None:          """          Adds a new documentation metadata object to the site's database.          The database will update the object, should an existing item with the specified `package_name` already exist. +        If the base url is not specified, a default created by removing the last segment of the inventory url is used.          Example:              !docs setdoc \                      python \ -                    https://docs.python.org/3/ \                      https://docs.python.org/3/objects.inv          """ -        if not base_url.endswith("/"): +        if base_url and not base_url.endswith("/"):              raise commands.BadArgument("The base url must end with a slash.")          inventory_url, inventory_dict = inventory          body = { @@ -381,13 +397,22 @@ class DocCog(commands.Cog):              "base_url": base_url,              "inventory_url": inventory_url          } -        await self.bot.api_client.post("bot/documentation-links", json=body) +        try: +            await self.bot.api_client.post("bot/documentation-links", json=body) +        except ResponseCodeError as err: +            if err.status == 400 and "already exists" in err.response_json.get("package", [""])[0]: +                log.info(f"Ignoring HTTP 400 as package {package_name} has already been added.") +                await ctx.send(f"Package {package_name} has already been added.") +                return +            raise          log.info(              f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n"              + "\n".join(f"{key}: {value}" for key, value in body.items())          ) +        if not base_url: +            base_url = self.base_url_from_inventory_url(inventory_url)          self.update_single(package_name, base_url, inventory_dict)          await ctx.send(f"Added the package `{package_name}` to the database and updated the inventories.") @@ -439,6 +464,7 @@ class DocCog(commands.Cog):      ) -> 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 ctx.send(f"Successfully cleared the cache for `{package_name}`.")          else:              await ctx.send("No keys matching the package found.") @@ -447,4 +473,4 @@ class DocCog(commands.Cog):          """Clear scheduled inventories, queued symbols and cleanup task on cog unload."""          self.inventory_scheduler.cancel_all()          self.init_refresh_task.cancel() -        asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") +        scheduling.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") diff --git a/bot/exts/info/doc/_html.py b/bot/exts/info/doc/_html.py index 94efd81b7..ca0a0ac4a 100644 --- a/bot/exts/info/doc/_html.py +++ b/bot/exts/info/doc/_html.py @@ -1,4 +1,3 @@ -import logging  import re  from functools import partial  from typing import Callable, Container, Iterable, List, Union @@ -6,9 +5,11 @@ from typing import Callable, Container, Iterable, List, Union  from bs4 import BeautifulSoup  from bs4.element import NavigableString, PageElement, SoupStrainer, Tag +from bot.log import get_logger +  from . import MAX_SIGNATURE_AMOUNT -log = logging.getLogger(__name__) +log = get_logger(__name__)  _UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")  _SEARCH_END_TAG_ATTRS = ( diff --git a/bot/exts/info/doc/_inventory_parser.py b/bot/exts/info/doc/_inventory_parser.py index 80d5841a0..e69246d47 100644 --- a/bot/exts/info/doc/_inventory_parser.py +++ b/bot/exts/info/doc/_inventory_parser.py @@ -1,4 +1,3 @@ -import logging  import re  import zlib  from collections import defaultdict @@ -7,8 +6,9 @@ from typing import AsyncIterator, DefaultDict, List, Optional, Tuple  import aiohttp  import bot +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  FAILED_REQUEST_ATTEMPTS = 3  _V2_LINE_RE = re.compile(r'(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)') @@ -16,6 +16,10 @@ _V2_LINE_RE = re.compile(r'(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)')  InventoryDict = DefaultDict[str, List[Tuple[str, str]]] +class InvalidHeaderError(Exception): +    """Raised when an inventory file has an invalid header.""" + +  class ZlibStreamReader:      """Class used for decoding zlib data of a stream line by line.""" @@ -80,19 +84,25 @@ async def _fetch_inventory(url: str) -> InventoryDict:          stream = response.content          inventory_header = (await stream.readline()).decode().rstrip() -        inventory_version = int(inventory_header[-1:]) -        await stream.readline()  # skip project name -        await stream.readline()  # skip project version +        try: +            inventory_version = int(inventory_header[-1:]) +        except ValueError: +            raise InvalidHeaderError("Unable to convert inventory version header.") + +        has_project_header = (await stream.readline()).startswith(b"# Project") +        has_version_header = (await stream.readline()).startswith(b"# Version") +        if not (has_project_header and has_version_header): +            raise InvalidHeaderError("Inventory missing project or version header.")          if inventory_version == 1:              return await _load_v1(stream)          elif inventory_version == 2:              if b"zlib" not in await stream.readline(): -                raise ValueError(f"Invalid inventory file at url {url}.") +                raise InvalidHeaderError("'zlib' not found in header of compressed inventory.")              return await _load_v2(stream) -        raise ValueError(f"Invalid inventory file at url {url}.") +        raise InvalidHeaderError("Incompatible inventory version.")  async def fetch_inventory(url: str) -> Optional[InventoryDict]: @@ -115,6 +125,8 @@ async def fetch_inventory(url: str) -> Optional[InventoryDict]:                  f"Failed to get inventory from {url}; "                  f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."              ) +        except InvalidHeaderError: +            raise          except Exception:              log.exception(                  f"An unexpected error has occurred during fetching of {url}; " diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py index 1a0d42c47..6ab38eb3d 100644 --- a/bot/exts/info/doc/_parsing.py +++ b/bot/exts/info/doc/_parsing.py @@ -1,6 +1,5 @@  from __future__ import annotations -import logging  import re  import string  import textwrap @@ -10,14 +9,17 @@ from typing import Collection, Iterable, Iterator, List, Optional, TYPE_CHECKING  from bs4 import BeautifulSoup  from bs4.element import NavigableString, Tag +from bot.log import get_logger  from bot.utils.helpers import find_nth_occurrence +  from . import MAX_SIGNATURE_AMOUNT  from ._html import get_dd_description, get_general_description, get_signatures  from ._markdown import DocMarkdownConverter +  if TYPE_CHECKING:      from ._cog import DocItem -log = logging.getLogger(__name__) +log = get_logger(__name__)  _WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")  _PARAMETERS_RE = re.compile(r"\((.+)\)") diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py index ad764816f..107f2344f 100644 --- a/bot/exts/info/doc/_redis_cache.py +++ b/bot/exts/info/doc/_redis_cache.py @@ -4,6 +4,7 @@ import datetime  from typing import Optional, TYPE_CHECKING  from async_rediscache.types.base import RedisObject, namespace_lock +  if TYPE_CHECKING:      from ._cog import DocItem @@ -24,8 +25,7 @@ class DocRedisCache(RedisObject):          All keys from a single page are stored together, expiring a week after the first set.          """ -        url_key = remove_suffix(item.relative_url_path, ".html") -        redis_key = f"{self.namespace}:{item.package}:{url_key}" +        redis_key = f"{self.namespace}:{item_key(item)}"          needs_expire = False          with await self._get_pool_connection() as connection: @@ -43,10 +43,36 @@ class DocRedisCache(RedisObject):      @namespace_lock      async def get(self, item: DocItem) -> Optional[str]:          """Return the Markdown content of the symbol `item` if it exists.""" -        url_key = remove_suffix(item.relative_url_path, ".html") +        with await self._get_pool_connection() as connection: +            return await connection.hget(f"{self.namespace}:{item_key(item)}", item.symbol_id, encoding="utf8") + +    @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.""" +        with await self._get_pool_connection() as connection: +            package_keys = [ +                package_key async for package_key in connection.iscan(match=f"{self.namespace}:{package}:*") +            ] +            if package_keys: +                await connection.delete(*package_keys) +                return True +            return False + +class StaleItemCounter(RedisObject): +    """Manage increment counters for stale `DocItem`s.""" + +    @namespace_lock +    async def increment_for(self, item: DocItem) -> int: +        """ +        Increment the counter for `item` by 1, set it to expire in 3 weeks and return the new value. + +        If the counter didn't exist, initialize it with 1. +        """ +        key = f"{self.namespace}:{item_key(item)}:{item.symbol_id}"          with await self._get_pool_connection() as connection: -            return await connection.hget(f"{self.namespace}:{item.package}:{url_key}", item.symbol_id, encoding="utf8") +            await connection.expire(key, WEEK_SECONDS * 3) +            return int(await connection.incr(key))      @namespace_lock      async def delete(self, package: str) -> bool: @@ -61,10 +87,6 @@ class DocRedisCache(RedisObject):              return False -def remove_suffix(string: str, suffix: str) -> str: -    """Remove `suffix` from end of `string`.""" -    # TODO replace usages with str.removesuffix on 3.9 -    if string.endswith(suffix): -        return string[:-len(suffix)] -    else: -        return string +def item_key(item: DocItem) -> str: +    """Get the redis redis key string from `item`.""" +    return f"{item.package}:{item.relative_url_path.removesuffix('.html')}" diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 21a6cf752..743dfdd3f 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -1,5 +1,5 @@  import itertools -import logging +import re  from collections import namedtuple  from contextlib import suppress  from typing import List, Union @@ -12,10 +12,11 @@ from rapidfuzz.utils import default_process  from bot import constants  from bot.constants import Channels, STAFF_PARTNERS_COMMUNITY_ROLES  from bot.decorators import redirect_output +from bot.log import get_logger  from bot.pagination import LinePaginator  from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__)  COMMANDS_PER_PAGE = 8  PREFIX = constants.Bot.prefix @@ -179,7 +180,10 @@ class CustomHelpCommand(HelpCommand):          except CommandError:              command_details += NOT_ALLOWED_TO_RUN_MESSAGE -        command_details += f"*{command.help or 'No details provided.'}*\n" +        # Remove line breaks from docstrings, if not used to separate paragraphs. +        # Allow overriding this behaviour via putting \u2003 at the start of a line. +        formatted_doc = re.sub("(?<!\n)\n(?![\n\u2003])", " ", command.help) +        command_details += f"*{formatted_doc or 'No details provided.'}*\n"          embed.description = command_details          return embed diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index ae547b1b8..1b3e28e79 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -1,5 +1,4 @@  import colorsys -import logging  import pprint  import textwrap  from collections import defaultdict @@ -16,12 +15,14 @@ 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.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 = logging.getLogger(__name__) +log = get_logger(__name__)  class Information(Cog): @@ -46,13 +47,13 @@ class Information(Cog):      @staticmethod      def join_role_stats(role_ids: list[int], guild: Guild, name: Optional[str] = None) -> dict[str, int]:          """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" -        members = 0 +        member_count = 0          for role_id in role_ids:              if (role := guild.get_role(role_id)) is not None: -                members += len(role.members) +                member_count += len(role.members)              else:                  raise NonExistentRoleError(role_id) -        return {name or role.name.title(): members} +        return {name or role.name.title(): member_count}      @staticmethod      def get_member_counts(guild: Guild) -> dict[str, int]: @@ -72,7 +73,8 @@ class Information(Cog):          """Return additional server info only visible in moderation channels."""          talentpool_info = ""          if cog := self.bot.get_cog("Talentpool"): -            talentpool_info = f"Nominated: {len(cog.watched_users)}\n" +            num_nominated = len(cog.cache) if cog.cache else "-" +            talentpool_info = f"Nominated: {num_nominated}\n"          bb_info = ""          if cog := self.bot.get_cog("Big Brother"): @@ -243,7 +245,7 @@ class Information(Cog):      async def create_user_embed(self, ctx: Context, user: MemberOrUser) -> Embed:          """Creates an embed containing information on the `user`.""" -        on_server = bool(ctx.guild.get_member(user.id)) +        on_server = bool(await get_or_fetch_member(ctx.guild, user.id))          created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE) @@ -313,7 +315,7 @@ class Information(Cog):          for field_name, field_content in fields:              embed.add_field(name=field_name, value=field_content, inline=False) -        embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) +        embed.set_thumbnail(url=user.display_avatar.url)          embed.colour = user.colour if user.colour != Colour.default() else Colour.blurple()          return embed @@ -460,11 +462,12 @@ class Information(Cog):          # remove trailing whitespace          return out.rstrip() -    @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) -    @group(invoke_without_command=True) -    @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) -    async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: -        """Shows information about the raw API response.""" +    async def send_raw_content(self, ctx: Context, message: Message, json: bool = False) -> None: +        """ +        Send information about the raw API response for a `discord.Message`. + +        If `json` is True, send the information in a copy-pasteable Python format. +        """          if ctx.author not in message.channel.members:              await ctx.send(":x: You do not have permissions to see the channel this message is in.")              return @@ -500,10 +503,17 @@ class Information(Cog):          for page in paginator.pages:              await ctx.send(page, allowed_mentions=AllowedMentions.none()) +    @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) +    @group(invoke_without_command=True) +    @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) +    async def raw(self, ctx: Context, message: Message) -> None: +        """Shows information about the raw API response.""" +        await self.send_raw_content(ctx, message) +      @raw.command()      async def json(self, ctx: Context, message: Message) -> None:          """Shows information about the raw API response in a copy-pasteable Python format.""" -        await ctx.invoke(self.raw, message=message, json=True) +        await self.send_raw_content(ctx, message, json=True)  def setup(bot: Bot) -> None: diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py index b11b34db0..259095b50 100644 --- a/bot/exts/info/pep.py +++ b/bot/exts/info/pep.py @@ -1,4 +1,3 @@ -import logging  from datetime import datetime, timedelta  from email.parser import HeaderParser  from io import StringIO @@ -9,9 +8,11 @@ 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 = logging.getLogger(__name__) +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-" @@ -32,7 +33,7 @@ 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() -        self.bot.loop.create_task(self.refresh_peps_urls()) +        scheduling.create_task(self.refresh_peps_urls(), event_loop=self.bot.loop)      async def refresh_peps_urls(self) -> None:          """Refresh PEP URLs listing in every 3 hours.""" diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 62498ce0b..c3d2e2a3c 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -1,5 +1,4 @@  import itertools -import logging  import random  import re  from contextlib import suppress @@ -10,6 +9,7 @@ from discord.utils import escape_markdown  from bot.bot import Bot  from bot.constants import Colours, NEGATIVE_REPLIES, RedirectOutput +from bot.log import get_logger  from bot.utils.messages import wait_for_deletion  URL = "https://pypi.org/pypi/{package}/json" @@ -20,7 +20,7 @@ PYPI_COLOURS = itertools.cycle((Colours.yellow, Colours.blue, Colours.white))  ILLEGAL_CHARACTERS = re.compile(r"[^-_.a-zA-Z0-9]+")  INVALID_INPUT_DELETE_DELAY = RedirectOutput.delete_delay -log = logging.getLogger(__name__) +log = get_logger(__name__)  class PyPi(Cog): diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 63eb4ac17..2fad9d2ab 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -1,4 +1,3 @@ -import logging  import re  import typing as t  from datetime import date, datetime @@ -11,6 +10,8 @@ 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/" @@ -22,7 +23,15 @@ THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id  AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" -log = logging.getLogger(__name__) +# By first matching everything within a codeblock, +# when matching markdown it won't be within a codeblock +MARKDOWN_REGEX = re.compile( +    r"(?P<codeblock>`.*?`)"  # matches everything within a codeblock +    r"|(?P<markdown>(?<!\\)[_|])",  # matches unescaped `_` and `|` +    re.DOTALL  # required to support multi-line codeblocks +) + +log = get_logger(__name__)  class PythonNews(Cog): @@ -33,8 +42,8 @@ class PythonNews(Cog):          self.webhook_names = {}          self.webhook: t.Optional[discord.Webhook] = None -        self.bot.loop.create_task(self.get_webhook_names()) -        self.bot.loop.create_task(self.get_webhook_and_channel()) +        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 start_tasks(self) -> None:          """Start the tasks for fetching new PEPs and mailing list messages.""" @@ -75,8 +84,11 @@ class PythonNews(Cog):      @staticmethod      def escape_markdown(content: str) -> str: -        """Escape the markdown underlines and spoilers.""" -        return re.sub(r"[_|]", lambda match: "\\" + match[0], content) +        """Escape the markdown underlines and spoilers that aren't in codeblocks.""" +        return MARKDOWN_REGEX.sub( +            lambda match: match.group("codeblock") or "\\" + match.group("markdown"), +            content +        )      async def post_pep_news(self) -> None:          """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" @@ -108,7 +120,7 @@ class PythonNews(Cog):              # Build an embed and send a webhook              embed = discord.Embed( -                title=new["title"], +                title=self.escape_markdown(new["title"]),                  description=self.escape_markdown(new["summary"]),                  timestamp=new_datetime,                  url=new["link"], @@ -128,7 +140,7 @@ class PythonNews(Cog):              self.bot.stats.incr("python_news.posted.pep")              if msg.channel.is_news(): -                log.trace("Publishing PEP annnouncement because it was in a news channel") +                log.trace("Publishing PEP announcement because it was in a news channel")                  await msg.publish()          # Apply new sent news to DB to avoid duplicate sending @@ -178,7 +190,7 @@ class PythonNews(Cog):                  # Build an embed and send a message to the webhook                  embed = discord.Embed( -                    title=thread_information["subject"], +                    title=self.escape_markdown(thread_information["subject"]),                      description=content[:1000] + f"... [continue reading]({link})" if len(content) > 1000 else content,                      timestamp=new_date,                      url=link, diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 28eb558a6..e1f2f5153 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -1,13 +1,12 @@ -import logging -  from discord import Colour, Embed  from discord.ext.commands import Cog, Context, Greedy, group  from bot.bot import Bot  from bot.constants import URLs +from bot.log import get_logger  from bot.pagination import LinePaginator -log = logging.getLogger(__name__) +log = get_logger(__name__)  BASE_URL = f"{URLs.site_schema}{URLs.site}" diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index bb91a8563..842647555 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -1,4 +1,3 @@ -import logging  import re  import time  from pathlib import Path @@ -10,10 +9,11 @@ from discord.ext.commands import Cog, Context, group  from bot import constants  from bot.bot import Bot  from bot.converters import TagNameConverter +from bot.log import get_logger  from bot.pagination import LinePaginator  from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__)  TEST_CHANNELS = (      constants.Channels.bot_commands, diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 6ac077b93..80ba10112 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -1,5 +1,3 @@ -import asyncio -import logging  import traceback  from collections import namedtuple  from datetime import datetime @@ -9,7 +7,7 @@ from typing import Optional, Union  from aioredis import RedisError  from async_rediscache import RedisCache  from dateutil.relativedelta import relativedelta -from discord import Colour, Embed, Forbidden, Member, User +from discord import Colour, Embed, Forbidden, Member, TextChannel, User  from discord.ext import tasks  from discord.ext.commands import Cog, Context, group, has_any_role @@ -17,13 +15,15 @@ from bot.bot import Bot  from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles  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.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 = logging.getLogger(__name__) +log = get_logger(__name__)  REJECTION_MESSAGE = """  Hi, {user} - Thanks for your interest in our server! @@ -69,7 +69,7 @@ class Defcon(Cog):          self.scheduler = Scheduler(self.__class__.__name__) -        self.bot.loop.create_task(self._sync_settings()) +        scheduling.create_task(self._sync_settings(), event_loop=self.bot.loop)      @property      def mod_log(self) -> ModLog: @@ -111,7 +111,7 @@ class Defcon(Cog):          if self.threshold:              now = datetime.utcnow() -            if now - member.created_at < relativedelta_to_timedelta(self.threshold): +            if now - member.created_at.replace(tzinfo=None) < relativedelta_to_timedelta(self.threshold):                  log.info(f"Rejecting user {member}: Account is too new")                  message_sent = False @@ -137,7 +137,7 @@ class Defcon(Cog):                  await self.mod_log.send_log_message(                      Icons.defcon_denied, Colours.soft_red, "Entry denied", -                    message, member.avatar_url_as(static_format="png") +                    message, member.display_avatar.url                  )      @group(name='defcon', aliases=('dc',), invoke_without_command=True) @@ -176,7 +176,7 @@ class Defcon(Cog):          """          if isinstance(threshold, int):              threshold = relativedelta(days=threshold) -        await self._update_threshold(ctx.author, threshold=threshold, expiry=expiry) +        await self._update_threshold(ctx.author, ctx.channel, threshold, expiry)      @defcon_group.command()      @has_any_role(Roles.admins) @@ -185,7 +185,12 @@ class Defcon(Cog):          role = ctx.guild.default_role          permissions = role.permissions -        permissions.update(send_messages=False, add_reactions=False, connect=False) +        permissions.update( +            send_messages=False, +            add_reactions=False, +            send_messages_in_threads=False, +            connect=False +        )          await role.edit(reason="DEFCON shutdown", permissions=permissions)          await ctx.send(f"{Action.SERVER_SHUTDOWN.value.emoji} Server shut down.") @@ -196,7 +201,12 @@ class Defcon(Cog):          role = ctx.guild.default_role          permissions = role.permissions -        permissions.update(send_messages=True, add_reactions=True, connect=True) +        permissions.update( +            send_messages=True, +            add_reactions=True, +            send_messages_in_threads=True, +            connect=True +        )          await role.edit(reason="DEFCON unshutdown", permissions=permissions)          await ctx.send(f"{Action.SERVER_OPEN.value.emoji} Server reopened.") @@ -205,10 +215,16 @@ class Defcon(Cog):          new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})"          self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) -        asyncio.create_task(self.channel.edit(topic=new_topic)) +        scheduling.create_task(self.channel.edit(topic=new_topic))      @defcon_settings.atomic_transaction -    async def _update_threshold(self, author: User, threshold: relativedelta, expiry: Optional[Expiry] = None) -> None: +    async def _update_threshold( +        self, +        author: User, +        channel: TextChannel, +        threshold: relativedelta, +        expiry: Optional[Expiry] = None +    ) -> None:          """Update the new threshold in the cog, cache, defcon channel, and logs, and additionally schedule expiry."""          self.threshold = threshold          if threshold == relativedelta(days=0):  # If the threshold is 0, we don't need to schedule anything @@ -248,9 +264,13 @@ class Defcon(Cog):          else:              channel_message = "removed" -        await self.channel.send( -            f"{action.value.emoji} DEFCON threshold {channel_message}{error}." -        ) +        message = f"{action.value.emoji} DEFCON threshold {channel_message}{error}." +        await self.channel.send(message) + +        # If invoked outside of #defcon send to `ctx.channel` too +        if channel != self.channel: +            await channel.send(message) +          await self._send_defcon_log(action, author)          self._update_channel_topic() @@ -258,7 +278,7 @@ class Defcon(Cog):      async def _remove_threshold(self) -> None:          """Resets the threshold back to 0.""" -        await self._update_threshold(self.bot.user, relativedelta(days=0)) +        await self._update_threshold(self.bot.user, self.channel, relativedelta(days=0))      @staticmethod      def _stringify_relativedelta(delta: relativedelta) -> str: diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 1d2206e27..566422e29 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -1,13 +1,13 @@ -import logging -  import discord  from discord.ext.commands import Cog, Context, command, has_any_role  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 -log = logging.getLogger(__name__) +log = get_logger(__name__)  class DMRelay(Cog): @@ -63,8 +63,9 @@ class DMRelay(Cog):          await ctx.send(paste_link)      async def cog_check(self, ctx: Context) -> bool: -        """Only allow moderators to invoke the commands in this cog.""" -        return await has_any_role(*MODERATION_ROLES).predicate(ctx) +        """Only allow moderators to invoke the commands in this cog in mod channels.""" +        return (await has_any_role(*MODERATION_ROLES).predicate(ctx) +                and is_mod_channel(ctx.channel))  def setup(bot: Bot) -> None: diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 561e0251e..e265e29d3 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,5 +1,4 @@  import asyncio -import logging  import typing as t  from datetime import datetime  from enum import Enum @@ -9,9 +8,11 @@ from discord.ext.commands import Cog  from bot.bot import Bot  from bot.constants import Channels, Colours, Emojis, Guild, Webhooks +from bot.log import get_logger +from bot.utils import scheduling  from bot.utils.messages import sub_clyde -log = logging.getLogger(__name__) +log = get_logger(__name__)  # Amount of messages for `crawl_task` to process at most on start-up - limited to 50  # as in practice, there should never be this many messages, and if there are, @@ -93,7 +94,7 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di          timestamp=datetime.utcnow(),          colour=colour,      ) -    embed.set_footer(text=footer, icon_url=actioned_by.avatar_url) +    embed.set_footer(text=footer, icon_url=actioned_by.display_avatar.url)      if incident.attachments:          attachment = incident.attachments[0]  # User-sent messages can only contain one attachment @@ -104,7 +105,7 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di          else:              embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url)  # Embed links the file      else: -        file = None +        file = discord.utils.MISSING      return embed, file @@ -190,7 +191,7 @@ class Incidents(Cog):          self.bot = bot          self.event_lock = asyncio.Lock() -        self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) +        self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop)      async def crawl_incidents(self) -> None:          """ @@ -252,7 +253,7 @@ class Incidents(Cog):              await webhook.send(                  embed=embed,                  username=sub_clyde(incident.author.name), -                avatar_url=incident.author.avatar_url, +                avatar_url=incident.author.display_avatar.url,                  file=attachment_file,              )          except Exception: @@ -275,7 +276,7 @@ class Incidents(Cog):              return payload.message_id == incident.id          coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) -        return self.bot.loop.create_task(coroutine) +        return scheduling.create_task(coroutine, event_loop=self.bot.loop)      async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None:          """ diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 6ba4e74e9..d4e96b10b 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -1,4 +1,3 @@ -import logging  import textwrap  import typing as t  from abc import abstractmethod @@ -16,10 +15,11 @@ 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.channel import is_mod_channel -log = logging.getLogger(__name__) +log = get_logger(__name__)  class InfractionScheduler: @@ -29,7 +29,7 @@ class InfractionScheduler:          self.bot = bot          self.scheduler = scheduling.Scheduler(self.__class__.__name__) -        self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) +        scheduling.create_task(self.reschedule_infractions(supported_infractions), event_loop=self.bot.loop)      def cog_unload(self) -> None:          """Cancel scheduled tasks.""" @@ -81,12 +81,16 @@ class InfractionScheduler:          apply_coro: t.Optional[t.Awaitable]      ) -> None:          """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" -        # Calculate the time remaining, in seconds, for the mute. -        expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) -        delta = (expiry - datetime.utcnow()).total_seconds() +        if infraction["expires_at"] is not None: +            # Calculate the time remaining, in seconds, for the mute. +            expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) +            delta = (expiry - datetime.utcnow()).total_seconds() +        else: +            # If the infraction is permanent, it is not possible to get the time remaining. +            delta = None -        # Mark as inactive if less than a minute remains. -        if delta < 60: +        # Mark as inactive if the infraction is not permanent and less than a minute remains. +        if delta is not None and delta < 60:              log.info(                  "Infraction will be deactivated instead of re-applied "                  "because less than 1 minute remains." @@ -161,11 +165,11 @@ class InfractionScheduler:          # send DMs to user that it doesn't share a guild with. If we were to          # 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"]: +        if not infraction["hidden"] and infr_type in {"ban", "kick"}:              dm_result = f"{constants.Emojis.failmail} "              dm_log_text = "\nDM: **Failed**" -            # Accordingly display whether the user was successfully notified via DM. +            # Accordingly update whether the user was successfully notified via DM.              if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon):                  dm_result = ":incoming_envelope: "                  dm_log_text = "\nDM: Sent" @@ -228,6 +232,16 @@ class InfractionScheduler:          else:              infr_message = f" **{purge}{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" +            # 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): +                    dm_result = ":incoming_envelope: " +                    dm_log_text = "\nDM: Sent" +          # Send a confirmation message to the invoking context.          log.trace(f"Sending infraction #{id_} confirmation message.")          await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") @@ -239,7 +253,7 @@ class InfractionScheduler:              icon_url=icon,              colour=Colours.soft_red,              title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", -            thumbnail=user.avatar_url_as(static_format="png"), +            thumbnail=user.display_avatar.url,              text=textwrap.dedent(f"""                  Member: {messages.format_user(user)}                  Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text} @@ -333,7 +347,7 @@ class InfractionScheduler:              icon_url=_utils.INFRACTION_ICONS[infr_type][1],              colour=Colours.soft_green,              title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", -            thumbnail=user.avatar_url_as(static_format="png"), +            thumbnail=user.display_avatar.url,              text="\n".join(f"{k}: {v}" for k, v in log_text.items()),              footer=footer,              content=log_content, @@ -450,7 +464,7 @@ class InfractionScheduler:              log_title = "expiration failed" if "Failure" in log_text else "expired"              user = self.bot.get_user(user_id) -            avatar = user.avatar_url_as(static_format="png") if user else None +            avatar = user.display_avatar.url if user else None              # Move reason to end so when reason is too long, this is not gonna cut out required items.              log_text["Reason"] = log_text.pop("Reason") diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index b20ef1d06..c0ef80e3d 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,4 +1,3 @@ -import logging  import typing as t  from datetime import datetime @@ -9,8 +8,9 @@ from bot.api import ResponseCodeError  from bot.constants import Colours, Icons  from bot.converters import MemberOrUser  from bot.errors import InvalidInfractedUserError +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  # apply icon, pardon icon  INFRACTION_ICONS = { @@ -27,16 +27,18 @@ RULES_URL = "https://pythondiscord.com/pages/rules"  # Type aliases  Infraction = t.Dict[str, t.Union[str, int, bool]] -APPEAL_EMAIL = "[email protected]" +APPEAL_SERVER_INVITE = "https://discord.gg/WXrCJxWBnm"  INFRACTION_TITLE = "Please review our rules" -INFRACTION_APPEAL_EMAIL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" +INFRACTION_APPEAL_SERVER_FOOTER = f"\n\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})."  INFRACTION_APPEAL_MODMAIL_FOOTER = ( -    'If you would like to discuss or appeal this infraction, ' -    'send a message to the ModMail bot' +    '\n\nIf you would like to discuss or appeal this infraction, ' +    'send a message to the ModMail bot.'  )  INFRACTION_AUTHOR_NAME = "Infraction information" +LONGEST_EXTRAS = max(len(INFRACTION_APPEAL_SERVER_FOOTER), len(INFRACTION_APPEAL_MODMAIL_FOOTER)) +  INFRACTION_DESCRIPTION_TEMPLATE = (      "**Type:** {type}\n"      "**Expires:** {expires}\n" @@ -170,8 +172,10 @@ async def notify_infraction(      )      # For case when other fields than reason is too long and this reach limit, then force-shorten string -    if len(text) > 4096: -        text = f"{text[:4093]}..." +    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      embed = discord.Embed(          description=text, @@ -182,10 +186,6 @@ async def notify_infraction(      embed.title = INFRACTION_TITLE      embed.url = RULES_URL -    embed.set_footer( -        text=INFRACTION_APPEAL_EMAIL_FOOTER if infr_type == 'Ban' else INFRACTION_APPEAL_MODMAIL_FOOTER -    ) -      return await send_private_embed(user, embed) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2f9083c29..e495a94b3 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -1,4 +1,3 @@ -import logging  import textwrap  import typing as t @@ -10,13 +9,15 @@ 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 +from bot.converters import Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser  from bot.decorators import respect_role_hierarchy  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._scheduler import InfractionScheduler +from bot.log import get_logger +from bot.utils.members import get_or_fetch_member  from bot.utils.messages import format_user -log = logging.getLogger(__name__) +log = get_logger(__name__)  class Infractions(InfractionScheduler, commands.Cog): @@ -53,7 +54,7 @@ class Infractions(InfractionScheduler, commands.Cog):      # region: Permanent infractions      @command() -    async def warn(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: +    async def warn(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None:          """Warn a user for the given reason."""          if not isinstance(user, Member):              await ctx.send(":x: The user doesn't appear to be on the server.") @@ -66,7 +67,7 @@ class Infractions(InfractionScheduler, commands.Cog):          await self.apply_infraction(ctx, infraction, user)      @command() -    async def kick(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: +    async def kick(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None:          """Kick a user for the given reason."""          if not isinstance(user, Member):              await ctx.send(":x: The user doesn't appear to be on the server.") @@ -78,7 +79,7 @@ class Infractions(InfractionScheduler, commands.Cog):      async def ban(          self,          ctx: Context, -        user: MemberOrUser, +        user: UnambiguousMemberOrUser,          duration: t.Optional[Expiry] = None,          *,          reason: t.Optional[str] = None @@ -94,7 +95,7 @@ class Infractions(InfractionScheduler, commands.Cog):      async def purgeban(          self,          ctx: Context, -        user: MemberOrUser, +        user: UnambiguousMemberOrUser,          duration: t.Optional[Expiry] = None,          *,          reason: t.Optional[str] = None @@ -110,7 +111,7 @@ class Infractions(InfractionScheduler, commands.Cog):      async def voiceban(          self,          ctx: Context, -        user: MemberOrUser, +        user: UnambiguousMemberOrUser,          duration: t.Optional[Expiry] = None,          *,          reason: t.Optional[str] @@ -128,7 +129,7 @@ class Infractions(InfractionScheduler, commands.Cog):      @command(aliases=["mute"])      async def tempmute(          self, ctx: Context, -        user: MemberOrUser, +        user: UnambiguousMemberOrUser,          duration: t.Optional[Expiry] = None,          *,          reason: t.Optional[str] = None @@ -162,7 +163,7 @@ class Infractions(InfractionScheduler, commands.Cog):      async def tempban(          self,          ctx: Context, -        user: MemberOrUser, +        user: UnambiguousMemberOrUser,          duration: Expiry,          *,          reason: t.Optional[str] = None @@ -188,7 +189,7 @@ class Infractions(InfractionScheduler, commands.Cog):      async def tempvoiceban(              self,              ctx: Context, -            user: MemberOrUser, +            user: UnambiguousMemberOrUser,              duration: Expiry,              *,              reason: t.Optional[str] @@ -214,7 +215,7 @@ class Infractions(InfractionScheduler, commands.Cog):      # region: Permanent shadow infractions      @command(hidden=True) -    async def note(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: +    async def note(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None:          """Create a private note for a user with the given reason without notifying the user."""          infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False)          if infraction is None: @@ -223,7 +224,7 @@ class Infractions(InfractionScheduler, commands.Cog):          await self.apply_infraction(ctx, infraction, user)      @command(hidden=True, aliases=['shadowban', 'sban']) -    async def shadow_ban(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: +    async def shadow_ban(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None:          """Permanently ban a user for the given reason without notifying the user."""          await self.apply_ban(ctx, user, reason, hidden=True) @@ -234,7 +235,7 @@ class Infractions(InfractionScheduler, commands.Cog):      async def shadow_tempban(          self,          ctx: Context, -        user: MemberOrUser, +        user: UnambiguousMemberOrUser,          duration: Expiry,          *,          reason: t.Optional[str] = None @@ -260,17 +261,17 @@ class Infractions(InfractionScheduler, commands.Cog):      # region: Remove infractions (un- commands)      @command() -    async def unmute(self, ctx: Context, user: MemberOrUser) -> None: +    async def unmute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None:          """Prematurely end the active mute infraction for the user."""          await self.pardon_infraction(ctx, "mute", user)      @command() -    async def unban(self, ctx: Context, user: MemberOrUser) -> None: +    async def unban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None:          """Prematurely end the active ban infraction for the user."""          await self.pardon_infraction(ctx, "ban", user)      @command(aliases=("uvban",)) -    async def unvoiceban(self, ctx: Context, user: MemberOrUser) -> None: +    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) @@ -314,6 +315,10 @@ class Infractions(InfractionScheduler, commands.Cog):      @respect_role_hierarchy(member_arg=2)      async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:          """Apply a kick infraction with kwargs passed to `post_infraction`.""" +        if user.top_role >= ctx.me.top_role: +            await ctx.send(":x: I can't kick users above or equal to me in the role hierarchy.") +            return +          infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs)          if infraction is None:              return @@ -340,6 +345,10 @@ class Infractions(InfractionScheduler, commands.Cog):          Will also remove the banned user from the Big Brother watch list if applicable.          """ +        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 +          # 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          active_infraction = await _utils.get_active_infraction(ctx, user, "ban", is_temporary) @@ -422,7 +431,7 @@ class Infractions(InfractionScheduler, commands.Cog):          notify: bool = True      ) -> t.Dict[str, str]:          """Remove a user's muted role, optionally DM them a notification, and return a log dict.""" -        user = guild.get_member(user_id) +        user = await get_or_fetch_member(guild, user_id)          log_text = {}          if user: @@ -470,7 +479,7 @@ class Infractions(InfractionScheduler, commands.Cog):          notify: bool = True      ) -> t.Dict[str, str]:          """Optionally DM the user a pardon notification and return a log dict.""" -        user = guild.get_member(user_id) +        user = await get_or_fetch_member(guild, user_id)          log_text = {}          if user: @@ -519,7 +528,7 @@ class Infractions(InfractionScheduler, commands.Cog):      async def cog_command_error(self, ctx: Context, error: Exception) -> None:          """Send a notification to the invoking context on a Union failure."""          if isinstance(error, commands.BadUnionArgument): -            if discord.User in error.converters or discord.Member in error.converters: +            if discord.User in error.converters or Member in error.converters:                  await ctx.send(str(error.errors[0]))                  error.handled = True diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 641ad0410..b1c8b64dc 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -1,4 +1,3 @@ -import logging  import textwrap  import typing as t  from datetime import datetime @@ -12,15 +11,18 @@ from discord.utils import escape_markdown  from bot import constants  from bot.bot import Bot -from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UserMentionOrID, allowed_strings +from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings +from bot.errors import InvalidInfraction  from bot.exts.moderation.infraction.infractions import Infractions  from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger  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 = logging.getLogger(__name__) +log = get_logger(__name__)  class ModManagement(commands.Cog): @@ -44,9 +46,22 @@ class ModManagement(commands.Cog):      # region: Edit infraction commands      @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True) -    async def infraction_group(self, ctx: Context) -> None: -        """Infraction manipulation commands.""" -        await ctx.send_help(ctx.command) +    async def infraction_group(self, ctx: Context, infraction: Infraction = None) -> None: +        """ +        Infraction manipulation 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`. +        """ +        if infraction is None: +            await ctx.send_help(ctx.command) +            return + +        embed = discord.Embed( +            title=f"Infraction #{infraction['id']}", +            colour=discord.Colour.orange() +        ) +        await self.send_infraction_list(ctx, embed, [infraction])      @infraction_group.command(name="append", aliases=("amend", "add", "a"))      async def infraction_append( @@ -126,10 +141,11 @@ class ModManagement(commands.Cog):          log_text = ""          if duration is not None and not infraction['active']: -            if reason is None: +            if (infr_type := infraction['type']) in ('note', 'warning'): +                await ctx.send(f":x: Cannot edit the expiration of a {infr_type}.") +            else:                  await ctx.send(":x: Cannot edit the expiration of an expired infraction.") -                return -            confirm_messages.append("expiry unchanged (infraction already expired)") +            return          elif isinstance(duration, str):              request_data['expires_at'] = None              confirm_messages.append("marked as permanent") @@ -176,11 +192,11 @@ class ModManagement(commands.Cog):          # Get information about the infraction's user          user_id = new_infraction['user'] -        user = ctx.guild.get_member(user_id) +        user = await get_or_fetch_member(ctx.guild, user_id)          if user:              user_text = messages.format_user(user) -            thumbnail = user.avatar_url_as(static_format="png") +            thumbnail = user.display_avatar.url          else:              user_text = f"<@{user_id}>"              thumbnail = None @@ -201,7 +217,7 @@ class ModManagement(commands.Cog):      # region: Search infractions      @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) -    async def infraction_search_group(self, ctx: Context, query: t.Union[UserMentionOrID, Snowflake, str]) -> None: +    async def infraction_search_group(self, ctx: Context, query: t.Union[UnambiguousUser, Snowflake, str]) -> None:          """Searches for infractions in the database."""          if isinstance(query, int):              await self.search_user(ctx, discord.Object(query)) @@ -210,7 +226,7 @@ class ModManagement(commands.Cog):          else:              await self.search_user(ctx, query) -    @infraction_search_group.command(name="user", aliases=("member", "id")) +    @infraction_search_group.command(name="user", aliases=("member", "userid"))      async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None:          """Search for infractions by member."""          infraction_list = await self.bot.api_client.get( @@ -331,13 +347,20 @@ class ModManagement(commands.Cog):          return all(checks)      # This cannot be static (must have a __func__ attribute). -    async def cog_command_error(self, ctx: Context, error: Exception) -> None: -        """Send a notification to the invoking context on a Union failure.""" +    async def cog_command_error(self, ctx: Context, error: commands.CommandError) -> None: +        """Handles errors for commands within this cog."""          if isinstance(error, commands.BadUnionArgument):              if discord.User in error.converters:                  await ctx.send(str(error.errors[0]))                  error.handled = True +        elif isinstance(error, InvalidInfraction): +            if error.infraction_arg.isdigit(): +                await ctx.send(f":x: Could not find an infraction with id `{error.infraction_arg}`.") +            else: +                await ctx.send(f":x: `{error.infraction_arg}` is not a valid integer infraction id.") +            error.handled = True +  def setup(bot: Bot) -> None:      """Load the ModManagement cog.""" diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 05a2bbe10..08c92b8f3 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -1,5 +1,4 @@  import json -import logging  import random  import textwrap  import typing as t @@ -14,10 +13,12 @@ from bot.bot import Bot  from bot.converters import Duration, Expiry  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._scheduler import InfractionScheduler +from bot.log import get_logger +from bot.utils.members import get_or_fetch_member  from bot.utils.messages import format_user  from bot.utils.time import format_infraction -log = logging.getLogger(__name__) +log = get_logger(__name__)  NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"  SUPERSTARIFY_DEFAULT_DURATION = "1h" @@ -132,6 +133,10 @@ class Superstarify(InfractionScheduler, Cog):          An optional reason can be provided, which would be added to a message stating their old nickname          and linking to the nickname policy.          """ +        if member.top_role >= ctx.me.top_role: +            await ctx.send(":x: I can't starify users above or equal to me in the role hierarchy.") +            return +          if await _utils.get_active_infraction(ctx, member, "superstar"):              return @@ -198,7 +203,7 @@ class Superstarify(InfractionScheduler, Cog):              return          guild = self.bot.get_guild(constants.Guild.id) -        user = guild.get_member(infraction["user"]) +        user = await get_or_fetch_member(guild, infraction["user"])          # Don't bother sending a notification if the user left the guild.          if not user: diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index 9eeeec074..ce9c220b3 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -1,6 +1,5 @@  import csv  import json -import logging  from datetime import timedelta  from io import StringIO  from typing import Dict, List, Optional @@ -14,11 +13,12 @@ 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.utils import send_to_paste_service +from bot.log import get_logger +from bot.utils import scheduling, send_to_paste_service  from bot.utils.channel import is_mod_channel  from bot.utils.scheduling import Scheduler -log = logging.getLogger(__name__) +log = get_logger(__name__)  BASE_HEADERS = {      "Content-Type": "application/json" @@ -40,7 +40,7 @@ class Metabase(Cog):          self.exports: Dict[int, List[Dict]] = {}  # Saves the output of each question, so internal eval can access it -        self.init_task = self.bot.loop.create_task(self.init_cog()) +        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.""" diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index be2245650..b90480f0d 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -1,7 +1,6 @@  import asyncio  import difflib  import itertools -import logging  import typing as t  from datetime import datetime  from itertools import zip_longest @@ -9,17 +8,18 @@ from itertools import zip_longest  import discord  from dateutil.relativedelta import relativedelta  from deepdiff import DeepDiff -from discord import Colour +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 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.messages import format_user  from bot.utils.time import humanize_delta -log = logging.getLogger(__name__) +log = get_logger(__name__)  GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.VoiceChannel] @@ -378,7 +378,7 @@ class ModLog(Cog, name="ModLog"):          await self.send_log_message(              Icons.guild_update, Colour.blurple(),              "Guild updated", message, -            thumbnail=after.icon_url_as(format="png") +            thumbnail=after.icon.with_static_format("png")          )      @Cog.listener() @@ -394,7 +394,7 @@ class ModLog(Cog, name="ModLog"):          await self.send_log_message(              Icons.user_ban, Colours.soft_red,              "User banned", format_user(member), -            thumbnail=member.avatar_url_as(static_format="png"), +            thumbnail=member.display_avatar.url,              channel_id=Channels.user_log          ) @@ -405,7 +405,7 @@ class ModLog(Cog, name="ModLog"):              return          now = datetime.utcnow() -        difference = abs(relativedelta(now, member.created_at)) +        difference = abs(relativedelta(now, member.created_at.replace(tzinfo=None)))          message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference) @@ -415,7 +415,7 @@ class ModLog(Cog, name="ModLog"):          await self.send_log_message(              Icons.sign_in, Colours.soft_green,              "User joined", message, -            thumbnail=member.avatar_url_as(static_format="png"), +            thumbnail=member.display_avatar.url,              channel_id=Channels.user_log          ) @@ -432,7 +432,7 @@ class ModLog(Cog, name="ModLog"):          await self.send_log_message(              Icons.sign_out, Colours.soft_red,              "User left", format_user(member), -            thumbnail=member.avatar_url_as(static_format="png"), +            thumbnail=member.display_avatar.url,              channel_id=Channels.user_log          ) @@ -449,7 +449,7 @@ class ModLog(Cog, name="ModLog"):          await self.send_log_message(              Icons.user_unban, Colour.blurple(),              "User unbanned", format_user(member), -            thumbnail=member.avatar_url_as(static_format="png"), +            thumbnail=member.display_avatar.url,              channel_id=Channels.mod_log          ) @@ -515,21 +515,50 @@ class ModLog(Cog, name="ModLog"):              colour=Colour.blurple(),              title="Member updated",              text=message, -            thumbnail=after.avatar_url_as(static_format="png"), +            thumbnail=after.display_avatar.url,              channel_id=Channels.user_log          ) +    def is_message_blacklisted(self, message: Message) -> bool: +        """Return true if the message is in a blacklisted thread or channel.""" +        # Ignore bots or DMs +        if message.author.bot or not message.guild: +            return True + +        return self.is_channel_ignored(message.channel.id) + +    def is_channel_ignored(self, channel_id: int) -> bool: +        """ +        Return true if the channel, or parent channel in the case of threads, passed should be ignored by modlog. + +        Currently ignored channels are: +        1. Channels not in the guild we care about (constants.Guild.id). +        2. Channels that mods do not have view permissions to +        3. Channels in constants.Guild.modlog_blacklist +        """ +        channel = self.bot.get_channel(channel_id) + +        # Ignore not found channels, DMs, and messages outside of the main guild. +        if not channel or not hasattr(channel, "guild") or channel.guild.id != GuildConstant.id: +            return True + +        # Look at the parent channel of a thread. +        if isinstance(channel, Thread): +            channel = channel.parent + +        # Mod team doesn't have view permission to the channel. +        if not channel.permissions_for(channel.guild.get_role(Roles.mod_team)).view_channel: +            return True + +        return channel.id in GuildConstant.modlog_blacklist +      @Cog.listener()      async def on_message_delete(self, message: discord.Message) -> None:          """Log message delete event to message change log."""          channel = message.channel          author = message.author -        # Ignore DMs. -        if not message.guild: -            return - -        if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist: +        if self.is_message_blacklisted(message):              return          self._cached_deletes.append(message.id) @@ -584,7 +613,7 @@ class ModLog(Cog, name="ModLog"):      @Cog.listener()      async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None:          """Log raw message delete event to message change log.""" -        if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.modlog_blacklist: +        if self.is_channel_ignored(event.channel_id):              return          await asyncio.sleep(1)  # Wait here in case the normal event was fired @@ -625,12 +654,7 @@ class ModLog(Cog, name="ModLog"):      @Cog.listener()      async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None:          """Log message edit event to message change log.""" -        if ( -            not msg_before.guild -            or msg_before.guild.id != GuildConstant.id -            or msg_before.channel.id in GuildConstant.modlog_blacklist -            or msg_before.author.bot -        ): +        if self.is_message_blacklisted(msg_before):              return          self._cached_edits.append(msg_before.id) @@ -707,12 +731,7 @@ class ModLog(Cog, name="ModLog"):          except discord.NotFound:  # Was deleted before we got the event              return -        if ( -            not message.guild -            or message.guild.id != GuildConstant.id -            or message.channel.id in GuildConstant.modlog_blacklist -            or message.author.bot -        ): +        if self.is_message_blacklisted(message):              return          await asyncio.sleep(1)  # Wait here in case the normal event was fired @@ -752,6 +771,64 @@ class ModLog(Cog, name="ModLog"):          )      @Cog.listener() +    async def on_thread_update(self, before: Thread, after: Thread) -> None: +        """Log thread archiving, un-archiving and name edits.""" +        if before.name != after.name: +            await self.send_log_message( +                Icons.hash_blurple, +                Colour.blurple(), +                "Thread name edited", +                ( +                    f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`): " +                    f"`{before.name}` -> `{after.name}`" +                ) +            ) +            return + +        if not before.archived and after.archived: +            colour = Colour.red() +            action = "archived" +            icon = Icons.hash_red +        elif before.archived and not after.archived: +            colour = Colour.green() +            action = "un-archived" +            icon = Icons.hash_green +        else: +            return + +        await self.send_log_message( +            icon, +            colour, +            f"Thread {action}", +            f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`) was {action}" +        ) + +    @Cog.listener() +    async def on_thread_delete(self, thread: Thread) -> None: +        """Log thread deletion.""" +        await self.send_log_message( +            Icons.hash_red, +            Colour.red(), +            "Thread deleted", +            f"Thread {thread.mention} (`{thread.id}`) from {thread.parent.mention} (`{thread.parent.id}`) deleted" +        ) + +    @Cog.listener() +    async def on_thread_join(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 + +        await self.send_log_message( +            Icons.hash_green, +            Colour.green(), +            "Thread created", +            f"Thread {thread.mention} (`{thread.id}`) from {thread.parent.mention} (`{thread.parent.id}`) created" +        ) + +    @Cog.listener()      async def on_voice_state_update(          self,          member: discord.Member, @@ -761,7 +838,8 @@ class ModLog(Cog, name="ModLog"):          """Log member voice state changes to the voice log channel."""          if (              member.guild.id != GuildConstant.id -            or (before.channel and before.channel.id in GuildConstant.modlog_blacklist) +            or (before.channel and self.is_channel_ignored(before.channel.id)) +            or (after.channel and self.is_channel_ignored(after.channel.id))          ):              return @@ -820,7 +898,7 @@ class ModLog(Cog, name="ModLog"):              colour=colour,              title="Voice state updated",              text=message, -            thumbnail=member.avatar_url_as(static_format="png"), +            thumbnail=member.display_avatar.url,              channel_id=Channels.voice_log          ) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 80c9f0c38..a7ccb8162 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -1,5 +1,4 @@  import datetime -import logging  from async_rediscache import RedisCache  from dateutil.parser import isoparse @@ -9,9 +8,11 @@ from discord.ext.commands import Cog, Context, group, has_any_role  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 -log = logging.getLogger(__name__) +log = get_logger(__name__)  class ModPings(Cog): @@ -29,7 +30,11 @@ class ModPings(Cog):          self.guild = None          self.moderators_role = None -        self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule") +        self.reschedule_task = scheduling.create_task( +            self.reschedule_roles(), +            name="mod-pings-reschedule", +            event_loop=self.bot.loop, +        )      async def reschedule_roles(self) -> None:          """Reschedule moderators role re-apply times.""" diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 95e2792c3..511520252 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -1,22 +1,24 @@  import json -import logging  import typing  from contextlib import suppress  from datetime import datetime, timedelta, timezone  from typing import Optional, OrderedDict, Union  from async_rediscache import RedisCache -from discord import Guild, PermissionOverwrite, TextChannel, VoiceChannel +from discord import Guild, PermissionOverwrite, TextChannel, Thread, VoiceChannel  from discord.ext import commands, tasks  from discord.ext.commands import Context +from discord.utils import MISSING  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 = logging.getLogger(__name__) +log = get_logger(__name__)  LOCK_NAMESPACE = "silence" @@ -47,7 +49,16 @@ class SilenceNotifier(tasks.Loop):      """Loop notifier for posting notices to `alert_channel` containing added channels."""      def __init__(self, alert_channel: TextChannel): -        super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None) +        super().__init__( +            self._notifier, +            seconds=1, +            minutes=0, +            hours=0, +            count=None, +            reconnect=True, +            loop=None, +            time=MISSING +        )          self._silenced_channels = {}          self._alert_channel = alert_channel @@ -104,7 +115,7 @@ class Silence(commands.Cog):          self.bot = bot          self.scheduler = Scheduler(self.__class__.__name__) -        self._init_task = self.bot.loop.create_task(self._async_init()) +        self._init_task = scheduling.create_task(self._async_init(), event_loop=self.bot.loop)      async def _async_init(self) -> None:          """Set instance attributes once the guild is available and reschedule unsilences.""" @@ -172,6 +183,12 @@ class Silence(commands.Cog):          channel_info = f"#{channel} ({channel.id})"          log.debug(f"{ctx.author} is silencing channel {channel_info}.") +        # Since threads don't have specific overrides, we cannot silence them individually. +        # The parent channel has to be muted or the thread should be archived. +        if isinstance(channel, Thread): +            await ctx.send(":x: Threads cannot be silenced.") +            return +          if not await self._set_silence_overwrites(channel, kick=kick):              log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.")              await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel, alert_target=False) @@ -222,7 +239,13 @@ class Silence(commands.Cog):          if isinstance(channel, TextChannel):              role = self._everyone_role              overwrite = channel.overwrites_for(role) -            prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) +            prev_overwrites = dict( +                send_messages=overwrite.send_messages, +                add_reactions=overwrite.add_reactions, +                create_private_threads=overwrite.create_private_threads, +                create_public_threads=overwrite.create_public_threads, +                send_messages_in_threads=overwrite.send_messages_in_threads +            )          else:              role = self._verified_voice_role @@ -322,7 +345,15 @@ class Silence(commands.Cog):          # Check if old overwrites were not stored          if prev_overwrites is None:              log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") -            overwrite.update(send_messages=None, add_reactions=None, speak=None, connect=None) +            overwrite.update( +                send_messages=None, +                add_reactions=None, +                create_private_threads=None, +                create_public_threads=None, +                send_messages_in_threads=None, +                speak=None, +                connect=None +            )          else:              overwrite.update(**json.loads(prev_overwrites)) diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index d8baff76a..9583597e0 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -1,4 +1,3 @@ -import logging  from typing import Optional  from dateutil.relativedelta import relativedelta @@ -8,9 +7,10 @@ from discord.ext.commands import Cog, Context, group, has_any_role  from bot.bot import Bot  from bot.constants import Channels, Emojis, MODERATION_ROLES  from bot.converters import DurationDelta +from bot.log import get_logger  from bot.utils import time -log = logging.getLogger(__name__) +log = get_logger(__name__)  SLOWMODE_MAX_DELAY = 21600  # seconds diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 01d2614b0..99bbd8721 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -1,4 +1,3 @@ -import logging  from datetime import timedelta, timezone  from operator import itemgetter @@ -10,15 +9,16 @@ from discord.ext import commands  from bot.bot import Bot  from bot.constants import ( -    Colours, Emojis, Guild, MODERATION_ROLES, Roles, -    STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission +    Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission  )  from bot.converters import Expiry +from bot.log import get_logger  from bot.pagination import LinePaginator -from bot.utils.scheduling import Scheduler +from bot.utils import scheduling +from bot.utils.members import get_or_fetch_member  from bot.utils.time import discord_timestamp, format_infraction_with_duration -log = logging.getLogger(__name__) +log = get_logger(__name__)  class Stream(commands.Cog): @@ -30,8 +30,8 @@ class Stream(commands.Cog):      def __init__(self, bot: Bot):          self.bot = bot -        self.scheduler = Scheduler(self.__class__.__name__) -        self.reload_task = self.bot.loop.create_task(self._reload_tasks_from_redis()) +        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.""" @@ -47,23 +47,17 @@ class Stream(commands.Cog):          """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() +        guild = self.bot.get_guild(Guild.id)          for key, value in items: -            member = self.bot.get_guild(Guild.id).get_member(key) +            member = await get_or_fetch_member(guild, key)              if not member: -                # Member isn't found in the cache -                try: -                    member = await self.bot.get_guild(Guild.id).fetch_member(key) -                except discord.errors.NotFound: -                    log.debug( -                        f"Member {key} left the guild before we could schedule " -                        "the revoking of their streaming permissions." -                    ) -                    await self.task_cache.delete(key) -                    continue -                except discord.HTTPException: -                    log.exception(f"Exception while trying to retrieve member {key} from Discord.") -                    continue +                log.debug( +                    "User with ID %d left the guild before their streaming permissions could be revoked.", +                    key +                ) +                await self.task_cache.delete(key) +                continue              revoke_time = Arrow.utcfromtimestamp(value)              log.debug(f"Scheduling {member} ({member.id}) to have streaming permission revoked at {revoke_time}") diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index bfe9b74b4..ed5571d2a 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -1,4 +1,3 @@ -import logging  import typing as t  import discord @@ -7,9 +6,10 @@ from discord.ext.commands import Cog, Context, command, has_any_role  from bot import constants  from bot.bot import Bot  from bot.decorators import in_whitelist +from bot.log import get_logger  from bot.utils.checks import InWhitelistCheckFailure -log = logging.getLogger(__name__) +log = get_logger(__name__)  # Sent via DMs once user joins the guild  ON_JOIN_MESSAGE = """ diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8494a1e2e..8fdc7c76b 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,5 +1,4 @@  import asyncio -import logging  from contextlib import suppress  from datetime import datetime, timedelta @@ -8,15 +7,15 @@ from async_rediscache import RedisCache  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.decorators import has_no_roles, in_whitelist  from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger  from bot.utils.checks import InWhitelistCheckFailure -log = logging.getLogger(__name__) +log = get_logger(__name__)  # Flag written to the cog's RedisCache as a value when the Member's (key) notification  # was already removed ~ this signals both that no further notifications should be sent, @@ -166,7 +165,10 @@ class VoiceGate(Cog):              return          checks = { -            "joined_at": ctx.author.joined_at > datetime.utcnow() - timedelta(days=GateConf.minimum_days_member), +            "joined_at": ( +                ctx.author.joined_at.replace(tzinfo=None) > datetime.utcnow() +                - timedelta(days=GateConf.minimum_days_member) +            ),              "total_messages": data["total_messages"] < GateConf.minimum_messages,              "voice_banned": data["voice_banned"],              "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 146426569..8f97130ca 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -1,5 +1,4 @@  import asyncio -import logging  import re  import textwrap  from abc import abstractmethod @@ -17,11 +16,13 @@ from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig,  from bot.exts.filters.token_remover import TokenRemover  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 +from bot.utils import CogABCMeta, messages, scheduling +from bot.utils.members import get_or_fetch_member  from bot.utils.time import get_time_delta -log = logging.getLogger(__name__) +log = get_logger(__name__)  URL_RE = re.compile(r"(https?://[^\s]+)") @@ -46,7 +47,7 @@ class WatchChannel(metaclass=CogABCMeta):          webhook_id: int,          api_endpoint: str,          api_default_params: dict, -        logger: logging.Logger, +        logger: CustomLogger,          *,          disable_header: bool = False      ) -> None: @@ -69,7 +70,7 @@ class WatchChannel(metaclass=CogABCMeta):          self.message_history = MessageHistory()          self.disable_header = disable_header -        self._start = self.bot.loop.create_task(self.start_watchchannel()) +        self._start = scheduling.create_task(self.start_watchchannel(), event_loop=self.bot.loop)      @property      def modlog(self) -> ModLog: @@ -169,7 +170,7 @@ class WatchChannel(metaclass=CogABCMeta):          """Queues up messages sent by watched users."""          if msg.author.id in self.watched_users:              if not self.consuming_messages: -                self._consume_task = self.bot.loop.create_task(self.consume_messages()) +                self._consume_task = scheduling.create_task(self.consume_messages(), event_loop=self.bot.loop)              self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)")              self.message_queue[msg.author.id][msg.channel.id].append(msg) @@ -199,7 +200,10 @@ class WatchChannel(metaclass=CogABCMeta):          if self.message_queue:              self.log.trace("Channel queue not empty: Continuing consuming queues") -            self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) +            self._consume_task = scheduling.create_task( +                self.consume_messages(delay_consumption=False), +                event_loop=self.bot.loop, +            )          else:              self.log.trace("Done consuming messages.") @@ -246,7 +250,7 @@ class WatchChannel(metaclass=CogABCMeta):              await self.webhook_send(                  cleaned_content,                  username=msg.author.display_name, -                avatar_url=msg.author.avatar_url +                avatar_url=msg.author.display_avatar.url              )          if msg.attachments: @@ -260,7 +264,7 @@ class WatchChannel(metaclass=CogABCMeta):                  await self.webhook_send(                      embed=e,                      username=msg.author.display_name, -                    avatar_url=msg.author.avatar_url +                    avatar_url=msg.author.display_avatar.url                  )              except discord.HTTPException as exc:                  self.log.exception( @@ -278,7 +282,7 @@ class WatchChannel(metaclass=CogABCMeta):          user_id = msg.author.id          guild = self.bot.get_guild(GuildConfig.id) -        actor = guild.get_member(self.watched_users[user_id]['actor']) +        actor = await get_or_fetch_member(guild, self.watched_users[user_id]['actor'])          actor = actor.display_name if actor else self.watched_users[user_id]['actor']          inserted_at = self.watched_users[user_id]['inserted_at'] @@ -297,7 +301,7 @@ class WatchChannel(metaclass=CogABCMeta):          embed = Embed(description=f"{msg.author.mention} {message_jump}")          embed.set_footer(text=textwrap.shorten(footer, width=256, placeholder="...")) -        await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) +        await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.display_avatar.url)      async def list_watched_users(          self, ctx: Context, oldest_first: bool = False, update_cache: bool = True @@ -352,7 +356,7 @@ class WatchChannel(metaclass=CogABCMeta):          list_data["info"] = {}          for user_id, user_data in watched_iter: -            member = ctx.guild.get_member(user_id) +            member = await get_or_fetch_member(ctx.guild, user_id)              line = f"• `{user_id}`"              if member:                  line += f" ({member.name}#{member.discriminator})" diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index 3aa253fea..ab37b1b80 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -1,4 +1,3 @@ -import logging  import textwrap  from collections import ChainMap @@ -9,8 +8,9 @@ from bot.constants import Channels, MODERATION_ROLES, Webhooks  from bot.converters import MemberOrUser  from bot.exts.moderation.infraction._utils import post_infraction  from bot.exts.moderation.watchchannels._watchchannel import WatchChannel +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  class BigBrother(WatchChannel, Cog, name="Big Brother"): @@ -87,11 +87,11 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):              return          if not await self.fetch_user_cache(): -            await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") +            await ctx.send(f":x: Updating the user cache failed, can't watch user {user.mention}")              return          if user.id in self.watched_users: -            await ctx.send(f":x: {user} is already being watched.") +            await ctx.send(f":x: {user.mention} is already being watched.")              return          # discord.User instances don't have a roles attribute @@ -103,7 +103,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):          if response is not None:              self.watched_users[user.id] = response -            msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." +            msg = f":white_check_mark: Messages sent by {user.mention} will now be relayed to Big Brother."              history = await self.bot.api_client.get(                  self.api_endpoint, @@ -156,7 +156,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):                  log.debug(f"Perma-banned user {user} was unwatched.")                  return              log.trace("User is not banned.  Sending message to channel") -            message = f":white_check_mark: Messages sent by {user} will no longer be relayed." +            message = f":white_check_mark: Messages sent by {user.mention} will no longer be relayed."          else:              log.trace("No active watches found for user.") diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index c297f70c2..2fafaec97 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -1,63 +1,82 @@ -import logging  import textwrap -from collections import ChainMap +from collections import ChainMap, defaultdict  from io import StringIO -from typing import Union +from typing import Optional, Union  import discord  from async_rediscache import RedisCache -from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent -from discord.ext.commands import Cog, Context, group, has_any_role +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, Webhooks -from bot.converters import MemberOrUser -from bot.exts.moderation.watchchannels._watchchannel import WatchChannel +from bot.constants import 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 time +from bot.utils import scheduling, 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 -log = logging.getLogger(__name__) +log = get_logger(__name__) -class TalentPool(WatchChannel, Cog, name="Talentpool"): -    """Relays messages of helper candidates to a watch channel to observe them.""" +class TalentPool(Cog, name="Talentpool"): +    """Used to nominate potential helper candidates."""      # RedisCache[str, bool]      # Can contain a single key, "autoreview_enabled", with the value a bool indicating if autoreview is enabled.      talentpool_settings = RedisCache()      def __init__(self, bot: Bot) -> None: -        super().__init__( -            bot, -            destination=Channels.talent_pool, -            webhook_id=Webhooks.talent_pool, -            api_endpoint='bot/nominations', -            api_default_params={'active': 'true', 'ordering': '-inserted_at'}, -            logger=log, -            disable_header=True, -        ) - +        self.bot = bot          self.reviewer = Reviewer(self.__class__.__name__, bot, self) -        self.bot.loop.create_task(self.schedule_autoreviews()) +        self.cache: Optional[defaultdict[dict]] = None +        self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} + +        self.initial_refresh_task = scheduling.create_task(self.refresh_cache(), event_loop=self.bot.loop) +        scheduling.create_task(self.schedule_autoreviews(), event_loop=self.bot.loop)      async def schedule_autoreviews(self) -> None:          """Reschedule reviews for active nominations if autoreview is enabled."""          if await self.autoreview_enabled(): +            # Wait for a populated cache first +            await self.initial_refresh_task              await self.reviewer.reschedule_reviews()          else: -            self.log.trace("Not scheduling reviews as autoreview is disabled.") +            log.trace("Not scheduling reviews as autoreview is disabled.")      async def autoreview_enabled(self) -> bool:          """Return whether automatic posting of nomination reviews is enabled."""          return await self.talentpool_settings.get(AUTOREVIEW_ENABLED_KEY, True) +    async def refresh_cache(self) -> bool: +        """Updates TalentPool users cache.""" +        # Wait until logged in to ensure bot api client exists +        await self.bot.wait_until_guild_available() +        try: +            data = await self.bot.api_client.get( +                'bot/nominations', +                params=self.api_default_params +            ) +        except ResponseCodeError as err: +            log.exception("Failed to fetch the currently nominated users from the API", exc_info=err) +            return False + +        self.cache = defaultdict(dict) + +        for entry in data: +            user_id = entry.pop('user') +            self.cache[user_id] = entry + +        return True +      @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) -    @has_any_role(*MODERATION_ROLES) +    @has_any_role(*STAFF_ROLES)      async def nomination_group(self, ctx: Context) -> None:          """Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""          await ctx.send_help(ctx.command) @@ -106,25 +125,29 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          else:              await ctx.send("Autoreview is currently disabled") -    @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) +    @nomination_group.command( +        name="nominees", +        aliases=("nominated", "all", "list", "watched"), +        root_aliases=("nominees",) +    )      @has_any_role(*MODERATION_ROLES) -    async def watched_command( +    async def list_command(          self,          ctx: Context,          oldest_first: bool = False,          update_cache: bool = True      ) -> None:          """ -        Shows the users that are currently being monitored in the talent pool. +        Shows the users that are currently in the talent pool.          The optional kwarg `oldest_first` can be used to order the list by oldest nomination.          The optional kwarg `update_cache` can be used to update the user          cache using the API before listing the users.          """ -        await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) +        await self.list_nominated_users(ctx, oldest_first=oldest_first, update_cache=update_cache) -    async def list_watched_users( +    async def list_nominated_users(          self,          ctx: Context,          oldest_first: bool = False, @@ -141,16 +164,27 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          The optional kwarg `update_cache` specifies whether the cache should          be refreshed by polling the API.          """ -        # TODO Once the watch channel is removed, this can be done in a smarter way, without splitting and overriding -        # the list_watched_users function. -        watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache) +        successful_update = False +        if update_cache: +            if not (successful_update := await self.refresh_cache()): +                await ctx.send(":warning: Unable to update cache. Data may be inaccurate.") -        if update_cache and not watched_data["updated"]: -            await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") +        nominations = self.cache.items() +        if oldest_first: +            nominations = reversed(nominations)          lines = [] -        for user_id, line in watched_data["info"].items(): -            if self.watched_users[user_id]['reviewed']: + +        for user_id, user_data in nominations: +            member = await get_or_fetch_member(ctx.guild, user_id) +            line = f"• `{user_id}`" +            if member: +                line += f" ({member.name}#{member.discriminator})" +            inserted_at = user_data['inserted_at'] +            line += f", added {get_time_delta(inserted_at)}" +            if not member:  # Cross off users who left the server. +                line = f"~~{line}~~" +            if user_data['reviewed']:                  line += " *(reviewed)*"              elif user_id in self.reviewer:                  line += " *(scheduled)*" @@ -160,7 +194,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              lines = ("There's nothing here yet.",)          embed = Embed( -            title=watched_data["title"], +            title=f"Talent Pool active nominations ({'updated' if update_cache and successful_update else 'cached'})",              color=Color.blue()          )          await LinePaginator.paginate(lines, ctx, embed, empty=False) @@ -169,26 +203,30 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):      @has_any_role(*MODERATION_ROLES)      async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None:          """ -        Shows talent pool monitored users ordered by oldest nomination. +        Shows talent pool users ordered by oldest nomination.          The optional kwarg `update_cache` can be used to update the user          cache using the API before listing the users.          """ -        await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) +        await ctx.invoke(self.list_command, oldest_first=True, update_cache=update_cache) -    @nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",)) +    @nomination_group.command( +        name="forcenominate", +        aliases=("fw", "forceadd", "fa", "fn", "forcewatch"), +        root_aliases=("forcenominate",) +    )      @has_any_role(*MODERATION_ROLES) -    async def force_watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: +    async def force_nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:          """          Adds the given `user` to the talent pool, from any channel.          A `reason` for adding the user to the talent pool is optional.          """ -        await self._watch_user(ctx, user, reason) +        await self._nominate_user(ctx, user, reason) -    @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) +    @nomination_group.command(name='nominate', aliases=("w", "add", "a", "watch"), root_aliases=("nominate",))      @has_any_role(*STAFF_ROLES) -    async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: +    async def nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:          """          Adds the given `user` to the talent pool. @@ -199,26 +237,26 @@ class TalentPool(WatchChannel, 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 forcewatch` to override this check." +                    "Use `!tp forcenominate` to override this check."                  )              else:                  await ctx.send(f":x: Nominations must be run in the <#{Channels.nominations}> channel")              return -        await self._watch_user(ctx, user, reason) +        await self._nominate_user(ctx, user, reason) -    async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None: +    async def _nominate_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None:          """Adds the given user to the talent pool."""          if user.bot: -            await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") +            await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. Only humans can be nominated.")              return          if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles):              await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:")              return -        if not await self.fetch_user_cache(): -            await ctx.send(f":x: Failed to update the user cache; can't add {user}") +        if not await self.refresh_cache(): +            await ctx.send(f":x: Failed to update the cache; can't add {user}")              return          if len(reason) > REASON_MAX_CHARS: @@ -227,7 +265,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          # Manual request with `raise_for_status` as False because we want the actual response          session = self.bot.api_client.session -        url = self.bot.api_client._url_for(self.api_endpoint) +        url = self.bot.api_client._url_for('bot/nominations')          kwargs = {              'json': {                  'actor': ctx.author.id, @@ -249,23 +287,12 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              else:                  resp.raise_for_status() -        self.watched_users[user.id] = response_data +        self.cache[user.id] = response_data          if await self.autoreview_enabled() and user.id not in self.reviewer:              self.reviewer.schedule_review(user.id) -        history = await self.bot.api_client.get( -            self.api_endpoint, -            params={ -                "user__id": str(user.id), -                "active": "false", -                "ordering": "-inserted_at" -            } -        ) -          msg = f"✅ The nomination for {user.mention} has been added to the talent pool" -        if history: -            msg += f"\n\n({len(history)} previous nominations in total)"          await ctx.send(msg) @@ -274,7 +301,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):      async def history_command(self, ctx: Context, user: MemberOrUser) -> None:          """Shows the specified user's nomination history."""          result = await self.bot.api_client.get( -            self.api_endpoint, +            'bot/nominations',              params={                  'user__id': str(user.id),                  'ordering': "-active,-inserted_at" @@ -288,7 +315,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              title=f"Nominations for {user.display_name} `({user.id})`",              color=Color.blue()          ) -        lines = [self._nomination_to_string(nomination) for nomination in result] +        lines = [await self._nomination_to_string(nomination) for nomination in result]          await LinePaginator.paginate(              lines,              ctx=ctx, @@ -298,42 +325,99 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              max_size=1000          ) -    @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) +    @nomination_group.command(name="end", aliases=("unwatch", "unnominate"), root_aliases=("unnominate",))      @has_any_role(*MODERATION_ROLES) -    async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: +    async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:          """          Ends the active nomination of the specified user with the given reason.          Providing a `reason` is required.          """          if len(reason) > REASON_MAX_CHARS: -            await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") +            await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.")              return -        if await self.unwatch(user.id, reason): -            await ctx.send(f":white_check_mark: Messages sent by {user.mention} will no longer be relayed") +        if await self.end_nomination(user.id, reason): +            await ctx.send(f":white_check_mark: Successfully un-nominated {user}")          else:              await ctx.send(":x: The specified user does not have an active nomination")      @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) -    @has_any_role(*MODERATION_ROLES) +    @has_any_role(*STAFF_ROLES)      async def nomination_edit_group(self, ctx: Context) -> None:          """Commands to edit nominations."""          await ctx.send_help(ctx.command)      @nomination_edit_group.command(name='reason') -    @has_any_role(*MODERATION_ROLES) -    async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: MemberOrUser, *, reason: str) -> None: -        """Edits the reason of a specific nominator in a specific active nomination.""" +    @has_any_role(*STAFF_ROLES) +    async def edit_reason_command( +        self, +        ctx: Context, +        nominee_or_nomination_id: Union[UnambiguousMemberOrUser, int], +        nominator: Optional[UnambiguousMemberOrUser] = None, +        *, +        reason: str +    ) -> None: +        """ +        Edit the nomination reason of a specific nominator for a given nomination. + +        If nominee_or_nomination_id resolves to a member or user, edit the currently active nomination for that person. +        Otherwise, if it's an int, look up that nomination ID to edit. + +        If no nominator is specified, assume the invoker is editing their own nomination reason. +        Otherwise, edit the reason from that specific nominator. + +        Raise a permission error if a non-mod staff member invokes this command on a +        specific nomination ID, or with an nominator other than themselves. +        """ +        # If not specified, assume the invoker is editing their own nomination reason. +        nominator = nominator or ctx.author + +        if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): +            if ctx.channel.id != Channels.nominations: +                await ctx.send(f":x: Nomination edits must be run in the <#{Channels.nominations}> channel") +                return + +            if nominator != ctx.author or isinstance(nominee_or_nomination_id, int): +                # Invoker has specified another nominator, or a specific nomination id +                raise BadArgument( +                    "Only moderators can edit specific nomination IDs, " +                    "or the reason of a nominator other than themselves." +                ) + +        await self._edit_nomination_reason( +            ctx, +            target=nominee_or_nomination_id, +            actor=nominator, +            reason=reason +        ) + +    async def _edit_nomination_reason( +        self, +        ctx: Context, +        *, +        target: Union[int, Member, User], +        actor: MemberOrUser, +        reason: str, +    ) -> None: +        """Edit a nomination reason in the database after validating the input."""          if len(reason) > REASON_MAX_CHARS: -            await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") +            await ctx.send(f":x: Maximum allowed characters for the reason is {REASON_MAX_CHARS}.")              return +        if isinstance(target, int): +            nomination_id = target +        else: +            if nomination := self.cache.get(target.id): +                nomination_id = nomination["id"] +            else: +                await ctx.send("No active nomination found for that member.") +                return          try: -            nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") +            nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}")          except ResponseCodeError as e:              if e.response.status == 404: -                self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") +                log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")                  await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")                  return              else: @@ -347,13 +431,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              await ctx.send(f":x: {actor.mention} doesn't have an entry in this nomination.")              return -        self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") +        log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}")          await self.bot.api_client.patch( -            f"{self.api_endpoint}/{nomination_id}", +            f"bot/nominations/{nomination_id}",              json={"actor": actor.id, "reason": reason}          ) -        await self.fetch_user_cache()  # Update cache +        await self.refresh_cache()  # Update cache          await ctx.send(":white_check_mark: Successfully updated nomination reason.")      @nomination_edit_group.command(name='end_reason') @@ -365,10 +449,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              return          try: -            nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") +            nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}")          except ResponseCodeError as e:              if e.response.status == 404: -                self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") +                log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")                  await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")                  return              else: @@ -378,13 +462,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              await ctx.send(":x: Can't edit the end reason of an active nomination.")              return -        self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") +        log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}")          await self.bot.api_client.patch( -            f"{self.api_endpoint}/{nomination_id}", +            f"bot/nominations/{nomination_id}",              json={"end_reason": reason}          ) -        await self.fetch_user_cache()  # Update cache. +        await self.refresh_cache()  # Update cache.          await ctx.send(":white_check_mark: Updated the end reason of the nomination!")      @nomination_group.command(aliases=('mr',)) @@ -419,7 +503,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):      @Cog.listener()      async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None:          """Remove `user` from the talent pool after they are banned.""" -        await self.unwatch(user.id, "User was banned.") +        await self.end_nomination(user.id, "User was banned.")      @Cog.listener()      async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: @@ -441,10 +525,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              log.info(f"Archiving nomination {message.id}")              await self.reviewer.archive_vote(message, emoji == Emojis.incident_actioned) -    async def unwatch(self, user_id: int, reason: str) -> bool: +    async def end_nomination(self, user_id: int, reason: str) -> bool:          """End the active nomination of a user with the given reason and return True on success."""          active_nomination = await self.bot.api_client.get( -            self.api_endpoint, +            'bot/nominations',              params=ChainMap(                  {"user__id": str(user_id)},                  self.api_default_params, @@ -459,23 +543,23 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          nomination = active_nomination[0]          await self.bot.api_client.patch( -            f"{self.api_endpoint}/{nomination['id']}", +            f"bot/nominations/{nomination['id']}",              json={'end_reason': reason, 'active': False}          ) -        self._remove_user(user_id) +        self.cache.pop(user_id)          if await self.autoreview_enabled():              self.reviewer.cancel(user_id)          return True -    def _nomination_to_string(self, nomination_object: dict) -> str: +    async def _nomination_to_string(self, nomination_object: dict) -> str:          """Creates a string representation of a nomination."""          guild = self.bot.get_guild(Guild.id)          entries = []          for site_entry in nomination_object["entries"]:              actor_id = site_entry["actor"] -            actor = guild.get_member(actor_id) +            actor = await get_or_fetch_member(guild, actor_id)              reason = site_entry["reason"] or "*None*"              created = time.format_infraction(site_entry["inserted_at"]) @@ -512,7 +596,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):                  {entries_string}                  End date: {end_date} -                Unwatch reason: {nomination_object["end_reason"]} +                Unnomination reason: {nomination_object["end_reason"]}                  ===============                  """              ) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 4d496a1f7..dcf73c2cb 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -1,6 +1,5 @@  import asyncio  import contextlib -import logging  import random  import re  import textwrap @@ -16,6 +15,8 @@ from discord.ext.commands import Context  from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import Channels, Colours, Emojis, Guild +from bot.log import get_logger +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 @@ -23,7 +24,7 @@ from bot.utils.time import get_time_delta, time_since  if typing.TYPE_CHECKING:      from bot.exts.recruitment.talentpool._cog import TalentPool -log = logging.getLogger(__name__) +log = get_logger(__name__)  # Maximum amount of days before an automatic review is posted.  MAX_DAYS_IN_POOL = 30 @@ -57,10 +58,8 @@ class Reviewer:          """Reschedule all active nominations to be reviewed at the appropriate time."""          log.trace("Rescheduling reviews")          await self.bot.wait_until_guild_available() -        # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function. -        await self._pool.fetch_user_cache() -        for user_id, user_data in self._pool.watched_users.items(): +        for user_id, user_data in self._pool.cache.items():              if not user_data["reviewed"]:                  self.schedule_review(user_id) @@ -68,7 +67,7 @@ class Reviewer:          """Schedules a single user for review."""          log.trace(f"Scheduling review of user with ID {user_id}") -        user_data = self._pool.watched_users.get(user_id) +        user_data = self._pool.cache.get(user_id)          inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None)          review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) @@ -96,24 +95,24 @@ class Reviewer:                  await last_message.add_reaction(reaction)          if update_database: -            nomination = self._pool.watched_users.get(user_id) -            await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) +            nomination = self._pool.cache.get(user_id) +            await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True})      async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]:          """Format a generic review of a user and return it with the reviewed emoji."""          log.trace(f"Formatting the review of {user_id}") -        # Since `watched_users` is a defaultdict, we should take care +        # Since `cache` is a defaultdict, we should take care          # not to accidentally insert the IDs of users that have no -        # active nominated by using the `watched_users.get(user_id)` -        # instead of `watched_users[user_id]`. -        nomination = self._pool.watched_users.get(user_id) +        # active nominated by using the `cache.get(user_id)` +        # instead of `cache[user_id]`. +        nomination = self._pool.cache.get(user_id)          if not nomination:              log.trace(f"There doesn't appear to be an active nomination for {user_id}")              return "", None          guild = self.bot.get_guild(Guild.id) -        member = guild.get_member(user_id) +        member = await get_or_fetch_member(guild, user_id)          if not member:              return ( @@ -332,7 +331,7 @@ class Reviewer:          """          log.trace(f"Fetching the nomination history data for {member.id}'s review")          history = await self.bot.api_client.get( -            self._pool.api_endpoint, +            "bot/nominations",              params={                  "user__id": str(member.id),                  "active": "false", @@ -390,18 +389,18 @@ class Reviewer:          Returns True if the user was successfully marked as reviewed, False otherwise.          """          log.trace(f"Updating user {user_id} as reviewed") -        await self._pool.fetch_user_cache() -        if user_id not in self._pool.watched_users: +        await self._pool.refresh_cache() +        if user_id not in self._pool.cache:              log.trace(f"Can't find a nominated user with id {user_id}")              await ctx.send(f":x: Can't find a currently nominated user with id `{user_id}`")              return False -        nomination = self._pool.watched_users.get(user_id) +        nomination = self._pool.cache.get(user_id)          if nomination["reviewed"]:              await ctx.send(":x: This nomination was already reviewed, but here's a cookie :cookie:")              return False -        await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) +        await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True})          if user_id in self._review_scheduler:              self._review_scheduler.cancel(user_id) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index d84709616..788692777 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -1,13 +1,14 @@ -import logging +from contextlib import suppress  from typing import Optional -from discord import Embed, TextChannel +from discord import Embed, Forbidden, TextChannel, Thread  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.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  class BotCog(Cog, name="Bot"): @@ -16,6 +17,20 @@ 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.""" diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index f78664527..fa5d38917 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -1,5 +1,4 @@  import functools -import logging  import typing as t  from enum import Enum @@ -11,10 +10,11 @@ from bot import exts  from bot.bot import Bot  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 = logging.getLogger(__name__) +log = get_logger(__name__)  UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions", f"{exts.__name__}.moderation.modlog"} @@ -36,7 +36,7 @@ class Extensions(commands.Cog):      def __init__(self, bot: Bot):          self.bot = bot -    @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) +    @group(name="extensions", aliases=("ext", "exts", "c", "cog", "cogs"), invoke_without_command=True)      async def extensions_group(self, ctx: Context) -> None:          """Load, unload, reload, and list loaded extensions."""          await ctx.send_help(ctx.command) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 5d2cd7611..879735945 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -1,6 +1,5 @@  import contextlib  import inspect -import logging  import pprint  import re  import textwrap @@ -15,9 +14,10 @@ from discord.ext.commands import Cog, Context, group, has_any_role, is_owner  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 -log = logging.getLogger(__name__) +log = get_logger(__name__)  class Internal(Cog): diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index cf0e3265e..43d371d87 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -32,7 +32,7 @@ class Latency(commands.Cog):          """          # datetime.datetime objects do not have the "milliseconds" attribute.          # It must be converted to seconds before converting to milliseconds. -        bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() * 1000 +        bot_ping = (datetime.utcnow() - ctx.message.created_at.replace(tzinfo=None)).total_seconds() * 1000          if bot_ping <= 0:              bot_ping = "Your clock is out of sync, could not calculate ping."          else: diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 2bed5157f..3cb9307a9 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -1,5 +1,3 @@ -import asyncio -import logging  import random  import textwrap  import typing as t @@ -11,26 +9,26 @@ from dateutil.parser import isoparse  from discord.ext.commands import Cog, Context, Greedy, group  from bot.bot import Bot -from bot.constants import ( -    Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, -    Roles, STAFF_PARTNERS_COMMUNITY_ROLES -) -from bot.converters import Duration, UserMentionOrID +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES +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.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 = logging.getLogger(__name__) +log = get_logger(__name__)  LOCK_NAMESPACE = "reminder"  WHITELISTED_CHANNELS = Guild.reminder_whitelist  MAXIMUM_REMINDERS = 5  Mentionable = t.Union[discord.Member, discord.Role] -ReminderMention = t.Union[UserMentionOrID, discord.Role] +ReminderMention = t.Union[UnambiguousUser, discord.Role]  class Reminders(Cog): @@ -40,7 +38,7 @@ class Reminders(Cog):          self.bot = bot          self.scheduler = Scheduler(self.__class__.__name__) -        self.bot.loop.create_task(self.reschedule_reminders()) +        scheduling.create_task(self.reschedule_reminders(), event_loop=self.bot.loop)      def cog_unload(self) -> None:          """Cancel scheduled tasks.""" @@ -80,7 +78,7 @@ class Reminders(Cog):                  f"Reminder {reminder['id']} invalid: "                  f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}."              ) -            asyncio.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) +            scheduling.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}"))          return is_valid, user, channel @@ -117,7 +115,7 @@ class Reminders(Cog):          if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES):              return False, "members/roles"          elif await has_no_roles_check(ctx, *MODERATION_ROLES): -            return all(isinstance(mention, discord.Member) for mention in mentions), "roles" +            return all(isinstance(mention, (discord.User, discord.Member)) for mention in mentions), "roles"          else:              return True, "" @@ -136,11 +134,12 @@ class Reminders(Cog):              await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!")              return False -    def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: +    async def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]:          """Converts Role and Member ids to their corresponding objects if possible."""          guild = self.bot.get_guild(Guild.id)          for mention_id in mention_ids: -            if mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id)): +            member = await get_or_fetch_member(guild, mention_id) +            if mentionable := (member or guild.get_role(mention_id)):                  yield mentionable      def schedule_reminder(self, reminder: dict) -> None: @@ -194,9 +193,9 @@ class Reminders(Cog):          embed.description = f"Here's your reminder: {reminder['content']}"          # Here the jump URL is in the format of base_url/guild_id/channel_id/message_id -        additional_mentions = ' '.join( -            mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) -        ) +        additional_mentions = ' '.join([ +            mentionable.mention async for mentionable in self.get_mentionables(reminder["mentions"]) +        ])          jump_url = reminder.get("jump_url")          embed.description += f"\n[Jump back to when you created the reminder]({jump_url})" @@ -337,10 +336,10 @@ class Reminders(Cog):              remind_datetime = isoparse(remind_at).replace(tzinfo=None)              time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE) -            mentions = ", ".join( +            mentions = ", ".join([                  # Both Role and User objects have the `name` attribute -                mention.name for mention in self.get_mentionables(mentions) -            ) +                mention.name async for mention in self.get_mentionables(mentions) +            ])              mention_string = f"\n**Mentions:** {mentions}" if mentions else ""              text = textwrap.dedent(f""" diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index b1f1ba6a8..fbfc58d0b 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -1,7 +1,6 @@  import asyncio  import contextlib  import datetime -import logging  import re  import textwrap  from functools import partial @@ -14,10 +13,11 @@ from discord.ext.commands import Cog, Context, command, guild_only  from bot.bot import Bot  from bot.constants import Categories, Channels, Roles, URLs  from bot.decorators import redirect_output -from bot.utils import send_to_paste_service +from bot.log import get_logger +from bot.utils import scheduling, send_to_paste_service  from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__)  ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}")  FORMATTED_CODE_REGEX = re.compile( @@ -219,7 +219,7 @@ class Snekbox(Cog):                  response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.")              else:                  response = await ctx.send(msg) -            self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,))) +            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']}")          return response diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 0139a6ad3..f69bab781 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -1,5 +1,4 @@  import difflib -import logging  import re  import unicodedata  from typing import Tuple, Union @@ -12,11 +11,12 @@ from bot.bot import Bot  from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES  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 -log = logging.getLogger(__name__) +log = get_logger(__name__)  ZEN_OF_PYTHON = """\  Beautiful is better than ugly. diff --git a/bot/log.py b/bot/log.py index 4e20c005e..b3cecdcf2 100644 --- a/bot/log.py +++ b/bot/log.py @@ -3,6 +3,7 @@ import os  import sys  from logging import Logger, handlers  from pathlib import Path +from typing import Optional, TYPE_CHECKING, cast  import coloredlogs  import sentry_sdk @@ -14,11 +15,38 @@ from bot import constants  TRACE_LEVEL = 5 +if TYPE_CHECKING: +    LoggerClass = Logger +else: +    LoggerClass = logging.getLoggerClass() + + +class CustomLogger(LoggerClass): +    """Custom implementation of the `Logger` class with an added `trace` method.""" + +    def trace(self, msg: str, *args, **kwargs) -> None: +        """ +        Log 'msg % args' with severity 'TRACE'. + +        To pass exception information, use the keyword argument exc_info with +        a true value, e.g. + +        logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) +        """ +        if self.isEnabledFor(TRACE_LEVEL): +            self.log(TRACE_LEVEL, msg, *args, **kwargs) + + +def get_logger(name: Optional[str] = None) -> CustomLogger: +    """Utility to make mypy recognise that logger is of type `CustomLogger`.""" +    return cast(CustomLogger, logging.getLogger(name)) + +  def setup() -> None:      """Set up loggers."""      logging.TRACE = TRACE_LEVEL      logging.addLevelName(TRACE_LEVEL, "TRACE") -    Logger.trace = _monkeypatch_trace +    logging.setLoggerClass(CustomLogger)      format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"      log_format = logging.Formatter(format_string) @@ -28,7 +56,7 @@ def setup() -> None:      file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")      file_handler.setFormatter(log_format) -    root_log = logging.getLogger() +    root_log = get_logger()      root_log.addHandler(file_handler)      if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: @@ -42,16 +70,16 @@ def setup() -> None:      if "COLOREDLOGS_LOG_FORMAT" not in os.environ:          coloredlogs.DEFAULT_LOG_FORMAT = format_string -    coloredlogs.install(level=logging.TRACE, logger=root_log, stream=sys.stdout) +    coloredlogs.install(level=TRACE_LEVEL, logger=root_log, stream=sys.stdout)      root_log.setLevel(logging.DEBUG if constants.DEBUG_MODE else logging.INFO) -    logging.getLogger("discord").setLevel(logging.WARNING) -    logging.getLogger("websockets").setLevel(logging.WARNING) -    logging.getLogger("chardet").setLevel(logging.WARNING) -    logging.getLogger("async_rediscache").setLevel(logging.WARNING) +    get_logger("discord").setLevel(logging.WARNING) +    get_logger("websockets").setLevel(logging.WARNING) +    get_logger("chardet").setLevel(logging.WARNING) +    get_logger("async_rediscache").setLevel(logging.WARNING)      # Set back to the default of INFO even if asyncio's debug mode is enabled. -    logging.getLogger("asyncio").setLevel(logging.INFO) +    get_logger("asyncio").setLevel(logging.INFO)      _set_trace_loggers() @@ -73,19 +101,6 @@ def setup_sentry() -> None:      ) -def _monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: -    """ -    Log 'msg % args' with severity 'TRACE'. - -    To pass exception information, use the keyword argument exc_info with -    a true value, e.g. - -    logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) -    """ -    if self.isEnabledFor(TRACE_LEVEL): -        self._log(TRACE_LEVEL, msg, args, **kwargs) - -  def _set_trace_loggers() -> None:      """      Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var. @@ -101,13 +116,13 @@ def _set_trace_loggers() -> None:      level_filter = constants.Bot.trace_loggers      if level_filter:          if level_filter.startswith("*"): -            logging.getLogger().setLevel(logging.TRACE) +            get_logger().setLevel(TRACE_LEVEL)          elif level_filter.startswith("!"): -            logging.getLogger().setLevel(logging.TRACE) +            get_logger().setLevel(TRACE_LEVEL)              for logger_name in level_filter.strip("!,").split(","): -                logging.getLogger(logger_name).setLevel(logging.DEBUG) +                get_logger(logger_name).setLevel(logging.DEBUG)          else:              for logger_name in level_filter.strip(",").split(","): -                logging.getLogger(logger_name).setLevel(logging.TRACE) +                get_logger(logger_name).setLevel(TRACE_LEVEL) diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py new file mode 100644 index 000000000..e56a19da2 --- /dev/null +++ b/bot/monkey_patches.py @@ -0,0 +1,51 @@ +from datetime import datetime, timedelta + +from discord import Forbidden, http +from discord.ext import commands + +from bot.log import get_logger + +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 (datetime.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 = datetime.utcnow() +            log.warning("Got a 403 from typing event!") +            pass + +    http.HTTPClient.send_typing = honeybadger_type diff --git a/bot/pagination.py b/bot/pagination.py index 26caa7db0..8f4353eb1 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -1,5 +1,4 @@  import asyncio -import logging  import typing as t  from contextlib import suppress  from functools import partial @@ -9,6 +8,7 @@ from discord.abc import User  from discord.ext.commands import Context, Paginator  from bot import constants +from bot.log import get_logger  from bot.utils import messages  FIRST_EMOJI = "\u23EE"   # [:track_previous:] @@ -19,7 +19,7 @@ DELETE_EMOJI = constants.Emojis.trashcan  # [:trashcan:]  PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI) -log = logging.getLogger(__name__) +log = get_logger(__name__)  class EmptyPaginatorEmbedError(Exception): diff --git a/bot/resources/tags/async-await.md b/bot/resources/tags/async-await.md index ff71ace07..01ab28fe3 100644 --- a/bot/resources/tags/async-await.md +++ b/bot/resources/tags/async-await.md @@ -2,27 +2,26 @@  Python provides the ability to run multiple tasks and coroutines simultaneously with the use of the `asyncio` library, which is included in the Python standard library. -This works by running these coroutines in an event loop, where the context of which coroutine is being run is switches periodically to allow all of them to run, giving the appearance of running at the same time. This is different to using threads or processes in that all code is run in the main process and thread, although it is possible to run coroutines in threads. +This works by running these coroutines in an event loop, where the context of the running coroutine switches periodically to allow all other coroutines to run, thus giving the appearance of running at the same time. This is different to using threads or processes in that all code runs in the main process and thread, although it is possible to run coroutines in other threads.  To call an async function we can either `await` it, or run it in an event loop which we get from `asyncio`. -To create a coroutine that can be used with asyncio we need to define a function using the async keyword: +To create a coroutine that can be used with asyncio we need to define a function using the `async` keyword:  ```py  async def main():      await something_awaitable()  ``` -Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function this would have raised an exception like: `SyntaxError: 'await' outside async function` +Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function, it would raise the exception `SyntaxError: 'await' outside async function` -To run the top level async function from outside of the event loop we can get an event loop from `asyncio`, and then use that loop to run the function: +To run the top level async function from outside the event loop we need to use [`asyncio.run()`](https://docs.python.org/3/library/asyncio-task.html#asyncio.run), like this:  ```py -from asyncio import get_event_loop +import asyncio  async def main():      await something_awaitable() -loop = get_event_loop() -loop.run_until_complete(main()) +asyncio.run(main())  ``` -Note that in the `run_until_complete()` where we appear to be calling `main()`, this does not execute the code in `main`, rather it returns a `coroutine` object which is then handled and run by the event loop via `run_until_complete()`. +Note that in the `asyncio.run()`, where we appear to be calling `main()`, this does not execute the code in `main`. Rather, it creates and returns a new `coroutine` object (i.e `main() is not main()`) which is then handled and run by the event loop via `asyncio.run()`.  To learn more about asyncio and its use, see the [asyncio documentation](https://docs.python.org/3/library/asyncio.html). diff --git a/bot/resources/tags/bot_var.md b/bot/resources/tags/botvar.md index 6833b3cd8..3db6ae7ac 100644 --- a/bot/resources/tags/bot_var.md +++ b/bot/resources/tags/botvar.md @@ -1,4 +1,4 @@ -Python allows you to set custom attributes to class instances, like your bot! By adding variables as attributes to your bot you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below: +Python allows you to set custom attributes to most objects, like your bot! By storing things as attributes of the bot object, you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below:  ```py  bot = commands.Bot(command_prefix="!") diff --git a/bot/resources/tags/contribute.md b/bot/resources/tags/contribute.md new file mode 100644 index 000000000..070975646 --- /dev/null +++ b/bot/resources/tags/contribute.md @@ -0,0 +1,12 @@ +**Contribute to Python Discord's Open Source Projects** +Looking to contribute to Open Source Projects for the first time? Want to add a feature or fix a bug on the bots on this server? We have on-going projects that people can contribute to, even if you've never contributed to open source before! + +**Projects to Contribute to** +• [Sir Lancebot](https://github.com/python-discord/sir-lancebot) - our fun, beginner-friendly bot +• [Python](https://github.com/python-discord/bot) - our utility & moderation bot +• [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/) +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/paste.md b/bot/resources/tags/paste.md index 2ed51def7..8c3c2985d 100644 --- a/bot/resources/tags/paste.md +++ b/bot/resources/tags/paste.md @@ -1,6 +1,6 @@  **Pasting large amounts of code**  If your code is too long to fit in a codeblock in discord, you can paste your code here: -https://paste.pydis.com/ +https://paste.pythondiscord.com/  After pasting your code, **save** it by clicking the floppy disk icon in the top right, or by typing `ctrl + S`. After doing that, the URL should **change**. Copy the URL and post it here so others can see it. diff --git a/bot/resources/tags/string-formatting.md b/bot/resources/tags/string-formatting.md new file mode 100644 index 000000000..707d19c90 --- /dev/null +++ b/bot/resources/tags/string-formatting.md @@ -0,0 +1,24 @@ +**String Formatting Mini-Language** +The String Formatting Language in Python is a powerful way to tailor the display of strings and other data structures. This string formatting mini language works for f-strings and `.format()`. + +Take a look at some of these examples! +```py +>>> my_num = 2134234523 +>>> print(f"{my_num:,}") +2,134,234,523 + +>>> my_smaller_num = -30.0532234 +>>> print(f"{my_smaller_num:=09.2f}") +-00030.05 + +>>> my_str = "Center me!" +>>> print(f"{my_str:-^20}") +-----Center me!----- + +>>> repr_str = "Spam \t Ham" +>>> print(f"{repr_str!r}") +'Spam \t Ham' +``` +**Full Specification & Resources** +[String Formatting Mini Language Specification](https://docs.python.org/3/library/string.html#format-specification-mini-language) +[pyformat.info](https://pyformat.info/) diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index e770fa86d..321737aac 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -1,4 +1,4 @@ -Please provide a full traceback to your exception in order for us to identify your issue. +Please provide the full traceback for your exception in order to help us identify your issue.  A full traceback could look like:  ```py @@ -6,13 +6,13 @@ Traceback (most recent call last):      File "tiny", line 3, in          do_something()      File "tiny", line 2, in do_something -        a = 6 / 0 -ZeroDivisionError: integer division or modulo by zero +        a = 6 / b +ZeroDivisionError: division by zero  ```  The best way to read your traceback is bottom to top. -• Identify the exception raised (e.g. ZeroDivisionError)   -• Make note of the line number, and navigate there in your program.   -• Try to understand why the error occurred.   +• 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/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) +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). diff --git a/bot/resources/tags/windows-path.md b/bot/resources/tags/windows-path.md index da8edf685..b2b0da029 100644 --- a/bot/resources/tags/windows-path.md +++ b/bot/resources/tags/windows-path.md @@ -1,30 +1,17 @@  **PATH on Windows** -If you have installed Python but you forgot to check the *Add Python to PATH* option during the installation you may still be able to access your installation with ease. +If you have installed Python but forgot to check the `Add Python to PATH` option during the installation, you may still be able to access your installation with ease. -If you did not uncheck the option to install the Python launcher then you will find a `py` command on your system. If you want to be able to open your Python installation by running `python` then your best option is to re-install Python. +If you did not uncheck the option to install the `py launcher`, then you'll instead have a `py` command which can be used in the same way. If you want to be able to access your Python installation via the `python` command, then your best option is to re-install Python (remembering to tick the `Add Python to PATH` checkbox). -Otherwise, you can access your install using the `py` command in Command Prompt. Where you may type something with the `python` command like: -``` -C:\Users\Username> python3 my_application_file.py -``` - -You can achieve the same result using the `py` command like this: -``` -C:\Users\Username> py -3 my_application_file.py -``` - -You can pass any options to the Python interpreter after you specify a version, for example, to install a Python module using `pip` you can run: -``` -C:\Users\Username> py -3 -m pip install numpy -``` +You can pass any options to the Python interpreter, e.g. to install the `[numpy](https://pypi.org/project/numpy/)` module from PyPI you can run `py -3 -m pip install numpy` or `python -m pip install numpy`. -You can also access different versions of Python using the version flag, like so: +You can also access different versions of Python using the version flag of the `py` command, like so:  ```  C:\Users\Username> py -3.7  ... Python 3.7 starts ...  C:\Users\Username> py -3.6 -... Python 3.6 stars ... +... Python 3.6 starts ...  C:\Users\Username> py -2  ... Python 2 (any version installed) starts ...  ``` diff --git a/bot/resources/tags/xy-problem.md b/bot/resources/tags/xy-problem.md index b77bd27e8..8c508f18c 100644 --- a/bot/resources/tags/xy-problem.md +++ b/bot/resources/tags/xy-problem.md @@ -1,7 +1,7 @@  **xy-problem** -Asking about your attempted solution rather than your actual problem. +The XY problem can be summarised as asking about your attempted solution, rather than your actual problem.  Often programmers will get distracted with a potential solution they've come up with, and will try asking for help getting it to work. However, it's possible this solution either wouldn't work as they expect, or there's a much better solution instead. -For more information and examples: http://xyproblem.info/ +For more information and examples, see http://xyproblem.info/ diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md index f96b7f853..68a0a0cdb 100644 --- a/bot/resources/tags/ytdl.md +++ b/bot/resources/tags/ytdl.md @@ -1,4 +1,4 @@ -Per [Python Discord's Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, pytube, or other YouTube video downloaders as their usage violates YouTube's Terms of Service. +Per [Python Discord's Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, pytube, or other YouTube video downloaders, as their usage violates YouTube's Terms of Service.  For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?gl=GB&template=terms), as of 2021-03-17:  ``` diff --git a/bot/resources/tags/zip.md b/bot/resources/tags/zip.md index 6b05f0282..6f3157f71 100644 --- a/bot/resources/tags/zip.md +++ b/bot/resources/tags/zip.md @@ -3,7 +3,7 @@ The zip function allows you to iterate through multiple iterables simultaneously  ```py  letters = 'abc'  numbers = [1, 2, 3] -# zip(letters, numbers) --> [('a', 1), ('b', 2), ('c', 3)] +# list(zip(letters, numbers)) --> [('a', 1), ('b', 2), ('c', 3)]  for letter, number in zip(letters, numbers):      print(letter, number)  ``` diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py index 41faf7ee8..d979ac5e7 100644 --- a/bot/rules/discord_emojis.py +++ b/bot/rules/discord_emojis.py @@ -4,7 +4,6 @@ from typing import Dict, Iterable, List, Optional, Tuple  from discord import Member, Message  from emoji import demojize -  DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>|:\w+:")  CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL) diff --git a/bot/rules/links.py b/bot/rules/links.py index ec75a19c5..c46b783c5 100644 --- a/bot/rules/links.py +++ b/bot/rules/links.py @@ -3,7 +3,6 @@ from typing import Dict, Iterable, List, Optional, Tuple  from discord import Member, Message -  LINK_RE = re.compile(r"(https?://[^\s]+)") diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 72603c521..b9e234857 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -1,12 +1,11 @@ -import logging -  import discord  import bot  from bot import constants  from bot.constants import Categories +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  def is_help_channel(channel: discord.TextChannel) -> bool: @@ -53,7 +52,7 @@ def is_in_category(channel: discord.TextChannel, category_id: int) -> bool:      return getattr(channel, "category_id", None) == category_id -async def try_get_channel(channel_id: int) -> discord.abc.GuildChannel: +async def get_or_fetch_channel(channel_id: int) -> discord.abc.GuildChannel:      """Attempt to get or fetch a channel and return it."""      log.trace(f"Getting the channel {channel_id}.") diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 3d0c8a50c..e7f2cfbda 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,23 +1,15 @@  import datetime -import logging  from typing import Callable, Container, Iterable, Optional, Union  from discord.ext.commands import ( -    BucketType, -    CheckFailure, -    Cog, -    Command, -    CommandOnCooldown, -    Context, -    Cooldown, -    CooldownMapping, -    NoPrivateMessage, -    has_any_role, +    BucketType, CheckFailure, Cog, Command, CommandOnCooldown, Context, Cooldown, CooldownMapping, NoPrivateMessage, +    has_any_role  )  from bot import constants +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  class ContextCheckFailure(CheckFailure): @@ -134,7 +126,7 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy      bypass = set(bypass_roles)      # this handles the actual cooldown logic -    buckets = CooldownMapping(Cooldown(rate, per, type)) +    buckets = CooldownMapping(Cooldown(rate, per), type)      # will be called after the command has been parse but before it has been invoked, ensures that      # the cooldown won't be updated if the user screws up their input to the command @@ -149,7 +141,7 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy          bucket = buckets.get_bucket(ctx.message)          retry_after = bucket.update_rate_limit(current)          if retry_after: -            raise CommandOnCooldown(bucket, retry_after) +            raise CommandOnCooldown(bucket, retry_after, type)      def wrapper(command: Command) -> Command:          # NOTE: this could be changed if a subclass of Command were to be used. I didn't see the need for it diff --git a/bot/utils/function.py b/bot/utils/function.py index 9bc44e753..55115d7d3 100644 --- a/bot/utils/function.py +++ b/bot/utils/function.py @@ -2,11 +2,12 @@  import functools  import inspect -import logging  import types  import typing as t -log = logging.getLogger(__name__) +from bot.log import get_logger + +log = get_logger(__name__)  Argument = t.Union[int, str]  BoundArgs = t.OrderedDict[str, t.Any] diff --git a/bot/utils/lock.py b/bot/utils/lock.py index ec6f92cd4..c039a4f25 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -1,6 +1,5 @@  import asyncio  import inspect -import logging  import types  from collections import defaultdict  from functools import partial @@ -8,10 +7,11 @@ from typing import Any, Awaitable, Callable, Hashable, Union  from weakref import WeakValueDictionary  from bot.errors import LockedResourceError +from bot.log import get_logger  from bot.utils import function  from bot.utils.function import command_wraps -log = logging.getLogger(__name__) +log = get_logger(__name__)  __lock_dicts = defaultdict(WeakValueDictionary)  _IdCallableReturn = Union[Hashable, Awaitable[Hashable]] diff --git a/bot/utils/members.py b/bot/utils/members.py new file mode 100644 index 000000000..77ddf1696 --- /dev/null +++ b/bot/utils/members.py @@ -0,0 +1,25 @@ +import typing as t + +import discord + +from bot.log import get_logger + +log = get_logger(__name__) + + +async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> t.Optional[discord.Member]: +    """ +    Attempt to get a member from cache; on failure fetch from the API. + +    Return `None` to indicate the member could not be found. +    """ +    if member := guild.get_member(member_id): +        log.trace("%s retrieved from cache.", member) +    else: +        try: +            member = await guild.fetch_member(member_id) +        except discord.errors.NotFound: +            log.trace("Failed to fetch %d from API.", member_id) +            return None +        log.trace("%s fetched from API.", member) +    return member diff --git a/bot/utils/messages.py b/bot/utils/messages.py index abeb04021..e55c07062 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,5 +1,4 @@  import asyncio -import logging  import random  import re  from functools import partial @@ -11,9 +10,10 @@ 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 = logging.getLogger(__name__) +log = get_logger(__name__)  def reaction_check( @@ -121,7 +121,7 @@ async def send_attachments(      """      webhook_send_kwargs = {          'username': message.author.display_name, -        'avatar_url': message.author.avatar_url, +        'avatar_url': message.author.display_avatar.url,      }      webhook_send_kwargs.update(kwargs)      webhook_send_kwargs['username'] = sub_clyde(webhook_send_kwargs['username']) diff --git a/bot/utils/regex.py b/bot/utils/regex.py index a8efe1446..d77f5950b 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -1,13 +1,14 @@  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")(?:[\/]|slash)"                                # / or 'slash' -    r"([a-zA-Z0-9\-]+)",                              # the invite code itself +    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  ) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index bb83b5c0d..7b4c8e2de 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,11 +1,12 @@  import asyncio  import contextlib  import inspect -import logging  import typing as t  from datetime import datetime  from functools import partial +from bot.log import get_logger +  class Scheduler:      """ @@ -27,7 +28,7 @@ class Scheduler:      def __init__(self, name: str):          self.name = name -        self._log = logging.getLogger(f"{__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: @@ -187,5 +188,5 @@ def _log_task_exception(task: asyncio.Task, *, suppressed_exceptions: t.Tuple[t.          exception = task.exception()          # Log the exception if one exists.          if exception and not isinstance(exception, suppressed_exceptions): -            log = logging.getLogger(__name__) +            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 db9c93d0f..439c8d500 100644 --- a/bot/utils/services.py +++ b/bot/utils/services.py @@ -1,12 +1,12 @@ -import logging  from typing import Optional  from aiohttp import ClientConnectorError  import bot  from bot.constants import URLs +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__)  FAILED_REQUEST_ATTEMPTS = 3 diff --git a/bot/utils/webhooks.py b/bot/utils/webhooks.py index 66f82ec66..9c916b63a 100644 --- a/bot/utils/webhooks.py +++ b/bot/utils/webhooks.py @@ -1,12 +1,12 @@ -import logging  from typing import Optional  import discord  from discord import Embed +from bot.log import get_logger  from bot.utils.messages import sub_clyde -log = logging.getLogger(__name__) +log = get_logger(__name__)  async def send_webhook( diff --git a/config-default.yml b/config-default.yml index baece5c51..b61d9c99c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -144,6 +144,8 @@ guild:          logs:               &LOGS           468520609152892958          moderators:         &MODS_CATEGORY  749736277464842262          modmail:            &MODMAIL        714494672835444826 +        appeals:            &APPEALS        890331800025563216 +        appeals2:           &APPEALS2       895417395261341766          voice:                              356013253765234688          summer_code_jam:                    861692638540857384 @@ -157,9 +159,10 @@ guild:          reddit:                     &REDDIT_CHANNEL     458224812528238616          # Development -        dev_contrib:        &DEV_CONTRIB    635950537262759947 -        dev_core:           &DEV_CORE       411200599653351425 -        dev_log:            &DEV_LOG        622895325144940554 +        dev_contrib:        &DEV_CONTRIB     635950537262759947 +        dev_core:           &DEV_CORE        411200599653351425 +        dev_voting:         &DEV_CORE_VOTING 839162966519447552 +        dev_log:            &DEV_LOG         622895325144940554          # Discussion          meta:                               429409067623251969 @@ -232,22 +235,22 @@ guild:          # Watch          big_brother_logs:   &BB_LOGS        468507907357409333 -        talent_pool:        &TALENT_POOL    534321732593647616      moderation_categories:          - *MODS_CATEGORY          - *MODMAIL          - *LOGS +        - *APPEALS +        - *APPEALS2      moderation_channels:          - *ADMINS          - *ADMIN_SPAM          - *MODS -    # Modlog cog ignores events which occur in these channels +    # Modlog cog explicitly ignores events which occur in these channels. +    # This is on top of implicitly ignoring events in channels that the mod team cannot view.      modlog_blacklist: -        - *ADMINS -        - *ADMINS_VOICE          - *ATTACH_LOG          - *MESSAGE_LOG          - *MOD_LOG @@ -305,7 +308,6 @@ guild:          duck_pond:                          637821475327311927          incidents_archive:                  720671599790915702          python_news:        &PYNEWS_WEBHOOK 704381182279942324 -        talent_pool:                        569145364800602132  filter: @@ -336,7 +338,6 @@ filter:          - *MESSAGE_LOG          - *MOD_LOG          - *STAFF_LOUNGE -        - *TALENT_POOL      role_whitelist:          - *ADMINS_ROLE @@ -358,14 +359,14 @@ urls:      connect_max_retries:       3      connect_cooldown:          5      site:        &DOMAIN       "pythondiscord.com" -    site_api:    &API          "pydis-api.default.svc.cluster.local" +    site_api:    &API          "site.default.svc.cluster.local/api"      site_api_schema:           "http://"      site_paste:  &PASTE  !JOIN ["paste.", *DOMAIN]      site_schema: &SCHEMA       "https://" -    site_staff:  &STAFF  !JOIN ["staff.", *DOMAIN] +    site_staff:  &STAFF  !JOIN [*SCHEMA, *DOMAIN, "/staff"]      paste_service:                      !JOIN [*SCHEMA, *PASTE, "/{key}"] -    site_logs_view:                     !JOIN [*SCHEMA, *STAFF, "/bot/logs"] +    site_logs_view:                     !JOIN [*STAFF, "/bot/logs"]      # Snekbox      snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval" diff --git a/docker-compose.yml b/docker-compose.yml index 0f0355dac..b3ca6baa4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,11 @@ services:        POSTGRES_DB: pysite        POSTGRES_PASSWORD: pysite        POSTGRES_USER: pysite +    healthcheck: +      test: ["CMD-SHELL", "pg_isready -U pysite"] +      interval: 2s +      timeout: 1s +      retries: 5    redis:      << : *logging @@ -31,6 +36,21 @@ services:      ports:        - "127.0.0.1:6379:6379" +  metricity: +    << : *logging +    restart: on-failure  # USE_METRICITY=false will stop the container, so this ensures it only restarts on error +    depends_on: +      postgres: +        condition: service_healthy +    image: ghcr.io/python-discord/metricity:latest +    env_file: +      - .env +    environment: +      DATABASE_URI: postgres://pysite:pysite@postgres/metricity +      USE_METRICITY: ${USE_METRICITY-false} +    volumes: +      - .:/tmp/bot:ro +    snekbox:      << : *logging      << : *restart_policy @@ -56,7 +76,7 @@ services:        - "127.0.0.1:8000:8000"      tty: true      depends_on: -      - postgres +      - metricity      environment:        DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite        METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity diff --git a/poetry.lock b/poetry.lock index a4ce5d1a9..16c599bd1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -140,14 +140,14 @@ testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3  [[package]]  name = "beautifulsoup4" -version = "4.9.3" +version = "4.10.0"  description = "Screen-scraping library"  category = "main"  optional = false -python-versions = "*" +python-versions = ">3.0.0"  [package.dependencies] -soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""} +soupsieve = ">1.2"  [package.extras]  html5lib = ["html5lib"] @@ -155,7 +155,7 @@ lxml = ["lxml"]  [[package]]  name = "certifi" -version = "2021.5.30" +version = "2021.10.8"  description = "Python package for providing Mozilla's CA Bundle."  category = "main"  optional = false @@ -163,7 +163,7 @@ python-versions = "*"  [[package]]  name = "cffi" -version = "1.14.6" +version = "1.15.0"  description = "Foreign Function Interface for Python calling C code."  category = "main"  optional = false @@ -174,7 +174,7 @@ pycparser = "*"  [[package]]  name = "cfgv" -version = "3.3.0" +version = "3.3.1"  description = "Validate configuration and produce human readable error messages."  category = "dev"  optional = false @@ -190,7 +190,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"  [[package]]  name = "charset-normalizer" -version = "2.0.4" +version = "2.0.7"  description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."  category = "dev"  optional = false @@ -264,22 +264,26 @@ murmur = ["mmh3"]  [[package]]  name = "discord.py" -version = "1.7.3" +version = "2.0.0a0"  description = "A Python wrapper for the Discord API"  category = "main"  optional = false -python-versions = ">=3.5.3" +python-versions = ">=3.8.0"  [package.dependencies]  aiohttp = ">=3.6.0,<3.8.0"  [package.extras] -docs = ["sphinx (==3.0.3)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport"] +docs = ["sphinx (==4.0.2)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport"] +speed = ["orjson (>=3.5.4)"]  voice = ["PyNaCl (>=1.3.0,<1.5)"] +[package.source] +type = "url" +url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"  [[package]]  name = "distlib" -version = "0.3.2" +version = "0.3.3"  description = "Distribution utilities"  category = "dev"  optional = false @@ -317,19 +321,20 @@ testing = ["pre-commit"]  [[package]]  name = "fakeredis" -version = "1.5.2" +version = "1.6.1"  description = "Fake implementation of redis API for testing purposes."  category = "main"  optional = false  python-versions = ">=3.5"  [package.dependencies] +packaging = "*"  redis = "<3.6.0"  six = ">=1.12"  sortedcontainers = "*"  [package.extras] -aioredis = ["aioredis (<2)"] +aioredis = ["aioredis"]  lua = ["lupa"]  [[package]] @@ -345,11 +350,15 @@ sgmllib3k = "*"  [[package]]  name = "filelock" -version = "3.0.12" +version = "3.3.1"  description = "A platform independent file lock."  category = "dev"  optional = false -python-versions = "*" +python-versions = ">=3.6" + +[package.extras] +docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"]  [[package]]  name = "flake8" @@ -366,14 +375,14 @@ pyflakes = ">=2.3.0,<2.4.0"  [[package]]  name = "flake8-annotations" -version = "2.6.2" +version = "2.7.0"  description = "Flake8 Type Annotation Checks"  category = "dev"  optional = false -python-versions = ">=3.6.1,<4.0.0" +python-versions = ">=3.6.2,<4.0.0"  [package.dependencies] -flake8 = ">=3.7,<4.0" +flake8 = ">=3.7,<5.0"  [[package]]  name = "flake8-bugbear" @@ -403,15 +412,20 @@ flake8 = ">=3"  pydocstyle = ">=2.1"  [[package]] -name = "flake8-import-order" -version = "0.18.1" -description = "Flake8 and pylama plugin that checks the ordering of import statements." +name = "flake8-isort" +version = "4.1.1" +description = "flake8 plugin that integrates isort ."  category = "dev"  optional = false  python-versions = "*"  [package.dependencies] -pycodestyle = "*" +flake8 = ">=3.2.1,<5" +isort = ">=4.3.5,<6" +testfixtures = ">=6.8.0,<7" + +[package.extras] +test = ["pytest-cov"]  [[package]]  name = "flake8-polyfill" @@ -437,14 +451,14 @@ flake8 = "*"  [[package]]  name = "flake8-tidy-imports" -version = "4.3.0" +version = "4.5.0"  description = "A flake8 plugin that helps you write tidier imports."  category = "dev"  optional = false  python-versions = ">=3.6"  [package.dependencies] -flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" +flake8 = ">=3.8.0,<5"  [[package]]  name = "flake8-todo" @@ -467,18 +481,18 @@ python-versions = ">=3.6"  [[package]]  name = "humanfriendly" -version = "9.2" +version = "10.0"  description = "Human friendly output for text interfaces using Python"  category = "main"  optional = false  python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"  [package.dependencies] -pyreadline = {version = "*", markers = "sys_platform == \"win32\""} +pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""}  [[package]]  name = "identify" -version = "2.2.11" +version = "2.3.0"  description = "File identification library for Python"  category = "dev"  optional = false @@ -489,7 +503,7 @@ license = ["editdistance-s"]  [[package]]  name = "idna" -version = "3.2" +version = "3.3"  description = "Internationalized Domain Names in Applications (IDNA)"  category = "main"  optional = false @@ -504,6 +518,20 @@ optional = false  python-versions = "*"  [[package]] +name = "isort" +version = "5.9.3" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] + +[[package]]  name = "lxml"  version = "4.6.3"  description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." @@ -539,7 +567,7 @@ python-versions = "*"  [[package]]  name = "more-itertools" -version = "8.8.0" +version = "8.10.0"  description = "More routines for operating on iterables, beyond itertools"  category = "main"  optional = false @@ -555,7 +583,7 @@ python-versions = ">=3.5"  [[package]]  name = "multidict" -version = "5.1.0" +version = "5.2.0"  description = "multidict implementation"  category = "main"  optional = false @@ -581,7 +609,7 @@ python-versions = ">=3.5"  name = "packaging"  version = "21.0"  description = "Core utilities for Python packages" -category = "dev" +category = "main"  optional = false  python-versions = ">=3.6" @@ -601,7 +629,7 @@ codegen = ["lxml"]  [[package]]  name = "pep8-naming" -version = "0.12.0" +version = "0.12.1"  description = "Check PEP-8 naming conventions, plugin for flake8"  category = "dev"  optional = false @@ -612,8 +640,22 @@ flake8 = ">=3.9.1"  flake8-polyfill = ">=1.0.2,<2"  [[package]] +name = "pip-licenses" +version = "3.5.3" +description = "Dump the software license list of Python packages installed with pip." +category = "dev" +optional = false +python-versions = "~=3.6" + +[package.dependencies] +PTable = "*" + +[package.extras] +test = ["docutils", "pytest-cov", "pytest-pycodestyle", "pytest-runner"] + +[[package]]  name = "platformdirs" -version = "2.2.0" +version = "2.4.0"  description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."  category = "dev"  optional = false @@ -625,18 +667,19 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock  [[package]]  name = "pluggy" -version = "0.13.1" +version = "1.0.0"  description = "plugin and hook calling mechanisms for python"  category = "dev"  optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6"  [package.extras]  dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"]  [[package]]  name = "pre-commit" -version = "2.13.0" +version = "2.15.0"  description = "A framework for managing and maintaining multi-language pre-commit hooks."  category = "dev"  optional = false @@ -662,6 +705,14 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"  test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]  [[package]] +name = "ptable" +version = "0.9.2" +description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format" +category = "dev" +optional = false +python-versions = "*" + +[[package]]  name = "py"  version = "1.10.0"  description = "library with cross-python path, ini-parsing, io, code, log facilities" @@ -725,21 +776,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"  name = "pyparsing"  version = "2.4.7"  description = "Python parsing module" -category = "dev" +category = "main"  optional = false  python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"  [[package]] -name = "pyreadline" -version = "2.1" -description = "A python implmementation of GNU readline." +name = "pyreadline3" +version = "3.3" +description = "A python implementation of GNU readline."  category = "main"  optional = false  python-versions = "*"  [[package]]  name = "pytest" -version = "6.2.4" +version = "6.2.5"  description = "pytest: simple powerful testing with Python"  category = "dev"  optional = false @@ -751,7 +802,7 @@ attrs = ">=19.2.0"  colorama = {version = "*", markers = "sys_platform == \"win32\""}  iniconfig = "*"  packaging = "*" -pluggy = ">=0.12,<1.0.0a1" +pluggy = ">=0.12,<2.0"  py = ">=1.8.2"  toml = "*" @@ -851,11 +902,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"  [[package]]  name = "rapidfuzz" -version = "1.4.1" +version = "1.7.1"  description = "rapid fuzzy string matching"  category = "main"  optional = false -python-versions = ">=3.5" +python-versions = ">=2.7" + +[package.extras] +full = ["numpy"]  [[package]]  name = "redis" @@ -896,7 +950,7 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]  [[package]]  name = "sentry-sdk" -version = "1.3.1" +version = "1.4.3"  description = "Python client for Sentry (https://sentry.io)"  category = "main"  optional = false @@ -985,6 +1039,19 @@ psutil = ">=5.7.2,<6.0.0"  toml = ">=0.10.0,<0.11.0"  [[package]] +name = "testfixtures" +version = "6.18.3" +description = "A collection of helpers and mock objects for unit tests and doc tests." +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +build = ["setuptools-git", "wheel", "twine"] +docs = ["sphinx", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"] +test = ["pytest (>=3.6)", "pytest-cov", "pytest-django", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"] + +[[package]]  name = "toml"  version = "0.10.2"  description = "Python Library for Tom's Obvious, Minimal Language" @@ -994,7 +1061,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"  [[package]]  name = "typing-extensions" -version = "3.10.0.0" +version = "3.10.0.2"  description = "Backported and Experimental Type Hints for Python 3.5+"  category = "main"  optional = false @@ -1002,7 +1069,7 @@ python-versions = "*"  [[package]]  name = "urllib3" -version = "1.26.6" +version = "1.26.7"  description = "HTTP library with thread-safe connection pooling, file post, and more."  category = "main"  optional = false @@ -1015,7 +1082,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]  [[package]]  name = "virtualenv" -version = "20.7.0" +version = "20.8.1"  description = "Virtual Python Environment builder"  category = "dev"  optional = false @@ -1034,7 +1101,7 @@ testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)",  [[package]]  name = "yarl" -version = "1.6.3" +version = "1.7.0"  description = "Yet another URL library"  category = "main"  optional = false @@ -1047,7 +1114,7 @@ multidict = ">=4.0"  [metadata]  lock-version = "1.1"  python-versions = "3.9.*" -content-hash = "f46fe1d2d9e0621e4e06d4c2ba5f6190ec4574ac6ca809abe8bf542a3b55204e" +content-hash = "e37923739c35ef349d57e324579acfe304cc7e6fc20ddc54205fc89f171ae94f"  [metadata.files]  aio-pika = [ @@ -1130,72 +1197,76 @@ attrs = [      {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"},  ]  beautifulsoup4 = [ -    {file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"}, -    {file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"}, -    {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, +    {file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"}, +    {file = "beautifulsoup4-4.10.0.tar.gz", hash = "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"},  ]  certifi = [ -    {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, -    {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, +    {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, +    {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},  ]  cffi = [ -    {file = "cffi-1.14.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c"}, -    {file = "cffi-1.14.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99"}, -    {file = "cffi-1.14.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819"}, -    {file = "cffi-1.14.6-cp27-cp27m-win32.whl", hash = "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20"}, -    {file = "cffi-1.14.6-cp27-cp27m-win_amd64.whl", hash = "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224"}, -    {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7"}, -    {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33"}, -    {file = "cffi-1.14.6-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534"}, -    {file = "cffi-1.14.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a"}, -    {file = "cffi-1.14.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5"}, -    {file = "cffi-1.14.6-cp35-cp35m-win32.whl", hash = "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca"}, -    {file = "cffi-1.14.6-cp35-cp35m-win_amd64.whl", hash = "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218"}, -    {file = "cffi-1.14.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f"}, -    {file = "cffi-1.14.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872"}, -    {file = "cffi-1.14.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195"}, -    {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d"}, -    {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b"}, -    {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb"}, -    {file = "cffi-1.14.6-cp36-cp36m-win32.whl", hash = "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a"}, -    {file = "cffi-1.14.6-cp36-cp36m-win_amd64.whl", hash = "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e"}, -    {file = "cffi-1.14.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5"}, -    {file = "cffi-1.14.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf"}, -    {file = "cffi-1.14.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69"}, -    {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56"}, -    {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c"}, -    {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762"}, -    {file = "cffi-1.14.6-cp37-cp37m-win32.whl", hash = "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771"}, -    {file = "cffi-1.14.6-cp37-cp37m-win_amd64.whl", hash = "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a"}, -    {file = "cffi-1.14.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0"}, -    {file = "cffi-1.14.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e"}, -    {file = "cffi-1.14.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346"}, -    {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc"}, -    {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd"}, -    {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc"}, -    {file = "cffi-1.14.6-cp38-cp38-win32.whl", hash = "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548"}, -    {file = "cffi-1.14.6-cp38-cp38-win_amd64.whl", hash = "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156"}, -    {file = "cffi-1.14.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d"}, -    {file = "cffi-1.14.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e"}, -    {file = "cffi-1.14.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c"}, -    {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202"}, -    {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f"}, -    {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87"}, -    {file = "cffi-1.14.6-cp39-cp39-win32.whl", hash = "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728"}, -    {file = "cffi-1.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2"}, -    {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"}, +    {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, +    {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, +    {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, +    {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, +    {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, +    {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, +    {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, +    {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, +    {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, +    {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, +    {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, +    {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, +    {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, +    {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, +    {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, +    {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, +    {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, +    {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, +    {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, +    {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, +    {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, +    {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, +    {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, +    {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, +    {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, +    {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, +    {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, +    {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, +    {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, +    {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, +    {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, +    {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, +    {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, +    {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, +    {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, +    {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, +    {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, +    {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, +    {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, +    {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, +    {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, +    {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, +    {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, +    {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, +    {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, +    {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, +    {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, +    {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, +    {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, +    {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},  ]  cfgv = [ -    {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, -    {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, +    {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.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"}, -    {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, +    {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"}, +    {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"},  ]  colorama = [      {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -1267,13 +1338,10 @@ deepdiff = [      {file = "deepdiff-4.3.2-py3-none-any.whl", hash = "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4"},      {file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"},  ] -"discord.py" = [ -    {file = "discord.py-1.7.3-py3-none-any.whl", hash = "sha256:c6f64db136de0e18e090f6752ea68bdd4ab0a61b82dfe7acecefa22d6477bb0c"}, -    {file = "discord.py-1.7.3.tar.gz", hash = "sha256:462cd0fe307aef8b29cbfa8dd613e548ae4b2cb581d46da9ac0d46fb6ea19408"}, -] +"discord.py" = []  distlib = [ -    {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, -    {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, +    {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"}, @@ -1286,24 +1354,24 @@ execnet = [      {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"},  ]  fakeredis = [ -    {file = "fakeredis-1.5.2-py3-none-any.whl", hash = "sha256:f1ffdb134538e6d7c909ddfb4fc5edeb4a73d0ea07245bc69b8135fbc4144b04"}, -    {file = "fakeredis-1.5.2.tar.gz", hash = "sha256:18fc1808d2ce72169d3f11acdb524a00ef96bd29970c6d34cfeb2edb3fc0c020"}, +    {file = "fakeredis-1.6.1-py3-none-any.whl", hash = "sha256:5eb1516f1fe1813e9da8f6c482178fc067af09f53de587ae03887ef5d9d13024"}, +    {file = "fakeredis-1.6.1.tar.gz", hash = "sha256:0d06a9384fb79da9f2164ce96e34eb9d4e2ea46215070805ea6fd3c174590b47"},  ]  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.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, -    {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, +    {file = "filelock-3.3.1-py3-none-any.whl", hash = "sha256:2b5eb3589e7fdda14599e7eb1a50e09b4cc14f34ed98b8ba56d33bfaafcbef2f"}, +    {file = "filelock-3.3.1.tar.gz", hash = "sha256:34a9f35f95c441e7b38209775d6e0337f9a3759f3565f6c5798f19618527c76f"},  ]  flake8 = [      {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},      {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"},  ]  flake8-annotations = [ -    {file = "flake8-annotations-2.6.2.tar.gz", hash = "sha256:0d6cd2e770b5095f09689c9d84cc054c51b929c41a68969ea1beb4b825cac515"}, -    {file = "flake8_annotations-2.6.2-py3-none-any.whl", hash = "sha256:d10c4638231f8a50c0a597c4efce42bd7b7d85df4f620a0ddaca526138936a4f"}, +    {file = "flake8-annotations-2.7.0.tar.gz", hash = "sha256:52e53c05b0c06cac1c2dec192ea2c36e85081238add3bd99421d56f574b9479b"}, +    {file = "flake8_annotations-2.7.0-py3-none-any.whl", hash = "sha256:3edfbbfb58e404868834fe6ec3eaf49c139f64f0701259f707d043185545151e"},  ]  flake8-bugbear = [      {file = "flake8-bugbear-20.11.1.tar.gz", hash = "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538"}, @@ -1313,9 +1381,9 @@ flake8-docstrings = [      {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"},      {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"},  ] -flake8-import-order = [ -    {file = "flake8-import-order-0.18.1.tar.gz", hash = "sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"}, -    {file = "flake8_import_order-0.18.1-py2.py3-none-any.whl", hash = "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543"}, +flake8-isort = [ +    {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"}, +    {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"},  ]  flake8-polyfill = [      {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, @@ -1326,8 +1394,8 @@ 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.3.0.tar.gz", hash = "sha256:e66d46f58ed108f36da920e7781a728dc2d8e4f9269e7e764274105700c0a90c"}, -    {file = "flake8_tidy_imports-4.3.0-py3-none-any.whl", hash = "sha256:d6e64cb565ca9474d13d5cb3f838b8deafb5fed15906998d4a674daf55bd6d89"}, +    {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"},  ]  flake8-todo = [      {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, @@ -1376,21 +1444,25 @@ hiredis = [      {file = "hiredis-2.0.0.tar.gz", hash = "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a"},  ]  humanfriendly = [ -    {file = "humanfriendly-9.2-py2.py3-none-any.whl", hash = "sha256:332da98c24cc150efcc91b5508b19115209272bfdf4b0764a56795932f854271"}, -    {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"}, +    {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, +    {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},  ]  identify = [ -    {file = "identify-2.2.11-py2.py3-none-any.whl", hash = "sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839"}, -    {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"}, +    {file = "identify-2.3.0-py2.py3-none-any.whl", hash = "sha256:d1e82c83d063571bb88087676f81261a4eae913c492dafde184067c584bc7c05"}, +    {file = "identify-2.3.0.tar.gz", hash = "sha256:fd08c97f23ceee72784081f1ce5125c8f53a02d3f2716dde79a6ab8f1039fea5"},  ]  idna = [ -    {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, -    {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, +    {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, +    {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},  ]  iniconfig = [      {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},      {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"}, +]  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"}, @@ -1448,51 +1520,86 @@ mccabe = [      {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},  ]  more-itertools = [ -    {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, -    {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, +    {file = "more-itertools-8.10.0.tar.gz", hash = "sha256:1debcabeb1df793814859d64a81ad7cb10504c24349368ccf214c664c474f41f"}, +    {file = "more_itertools-8.10.0-py3-none-any.whl", hash = "sha256:56ddac45541718ba332db05f464bebfb0768110111affd27f66e0051f276fa43"},  ]  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.1.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f"}, -    {file = "multidict-5.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf"}, -    {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281"}, -    {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d"}, -    {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d"}, -    {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da"}, -    {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224"}, -    {file = "multidict-5.1.0-cp36-cp36m-win32.whl", hash = "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26"}, -    {file = "multidict-5.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6"}, -    {file = "multidict-5.1.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76"}, -    {file = "multidict-5.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a"}, -    {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f"}, -    {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348"}, -    {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93"}, -    {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9"}, -    {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37"}, -    {file = "multidict-5.1.0-cp37-cp37m-win32.whl", hash = "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5"}, -    {file = "multidict-5.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632"}, -    {file = "multidict-5.1.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952"}, -    {file = "multidict-5.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79"}, -    {file = "multidict-5.1.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456"}, -    {file = "multidict-5.1.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7"}, -    {file = "multidict-5.1.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635"}, -    {file = "multidict-5.1.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a"}, -    {file = "multidict-5.1.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea"}, -    {file = "multidict-5.1.0-cp38-cp38-win32.whl", hash = "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656"}, -    {file = "multidict-5.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3"}, -    {file = "multidict-5.1.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93"}, -    {file = "multidict-5.1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647"}, -    {file = "multidict-5.1.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d"}, -    {file = "multidict-5.1.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8"}, -    {file = "multidict-5.1.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1"}, -    {file = "multidict-5.1.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841"}, -    {file = "multidict-5.1.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda"}, -    {file = "multidict-5.1.0-cp39-cp39-win32.whl", hash = "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"}, -    {file = "multidict-5.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359"}, -    {file = "multidict-5.1.0.tar.gz", hash = "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5"}, +    {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"},  ]  nodeenv = [      {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, @@ -1510,20 +1617,24 @@ pamqp = [      {file = "pamqp-2.3.0.tar.gz", hash = "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"},  ]  pep8-naming = [ -    {file = "pep8-naming-0.12.0.tar.gz", hash = "sha256:1f9a3ecb2f3fd83240fd40afdd70acc89695c49c333413e49788f93b61827e12"}, -    {file = "pep8_naming-0.12.0-py2.py3-none-any.whl", hash = "sha256:2321ac2b7bf55383dd19a6a9c8ae2ebf05679699927a3af33e60dd7d337099d3"}, +    {file = "pep8-naming-0.12.1.tar.gz", hash = "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"}, +    {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"}, +] +pip-licenses = [ +    {file = "pip-licenses-3.5.3.tar.gz", hash = "sha256:f44860e00957b791c6c6005a3328f2d5eaeee96ddb8e7d87d4b0aa25b02252e4"}, +    {file = "pip_licenses-3.5.3-py3-none-any.whl", hash = "sha256:59c148d6a03784bf945d232c0dc0e9de4272a3675acaa0361ad7712398ca86ba"},  ]  platformdirs = [ -    {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, -    {file = "platformdirs-2.2.0.tar.gz", hash = "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"}, +    {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, +    {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"},  ]  pluggy = [ -    {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, -    {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +    {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.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, -    {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, +    {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"},  ]  psutil = [      {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, @@ -1555,6 +1666,9 @@ psutil = [      {file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"},      {file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"},  ] +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"}, @@ -1614,14 +1728,13 @@ pyparsing = [      {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},      {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},  ] -pyreadline = [ -    {file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"}, -    {file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"}, -    {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, +pyreadline3 = [ +    {file = "pyreadline3-3.3-py3-none-any.whl", hash = "sha256:0003fd0079d152ecbd8111202c5a7dfa6a5569ffd65b235e45f3c2ecbee337b4"}, +    {file = "pyreadline3-3.3.tar.gz", hash = "sha256:ff3b5a1ac0010d0967869f723e687d42cabc7dccf33b14934c92aa5168d260b3"},  ]  pytest = [ -    {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, -    {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, +    {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, +    {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"},  ]  pytest-cov = [      {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -1679,67 +1792,57 @@ pyyaml = [      {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},  ]  rapidfuzz = [ -    {file = "rapidfuzz-1.4.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:72878878d6744883605b5453c382361716887e9e552f677922f76d93d622d8cb"}, -    {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:56a67a5b3f783e9af73940f6945366408b3a2060fc6ab18466e5a2894fd85617"}, -    {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f5d396b64f8ae3a793633911a1fb5d634ac25bf8f13d440139fa729131be42d8"}, -    {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4990698233e7eda7face7c09f5874a09760c7524686045cbb10317e3a7f3225f"}, -    {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a87e212855b18a951e79ec71d71dbd856d98cd2019d0c2bd46ec30688a8aa68a"}, -    {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1897d2ef03f5b51bc19bdb2d0398ae968766750fa319843733f0a8f12ddde986"}, -    {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:e1fc4fd219057f5f1fa40bb9bc5e880f8ef45bf19350d4f5f15ca2ce7f61c99b"}, -    {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:21300c4d048798985c271a8bf1ed1611902ebd4479fcacda1a3eaaebbad2f744"}, -    {file = "rapidfuzz-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:d2659967c6ac74211a87a1109e79253e4bc179641057c64800ef4e2dc0534fdb"}, -    {file = "rapidfuzz-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:26ac4bfe564c516e053fc055f1543d2b2433338806738c7582e1f75ed0485f7e"}, -    {file = "rapidfuzz-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3b485c98ad1ce3c04556f65aaab5d6d6d72121cde656d43505169c71ae956476"}, -    {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:59db06356eaf22c83f44b0dded964736cbb137291cdf2cf7b4974c0983b94932"}, -    {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fef95249af9a535854b617a68788c38cd96308d97ee14d44bc598cc73e986167"}, -    {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7d8c186e8270e103d339b26ef498581cf3178470ccf238dfd5fd0e47d80e4c7d"}, -    {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:9246b9c5c8992a83a08ac7813c8bbff2e674ad0b681f9b3fb1ec7641eff6c21f"}, -    {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f58c17f7a82b1bcc2ce304942cae14287223e6b6eead7071241273da7d9b9770"}, -    {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:ed708620b23a09ac52eaaec0761943c1bbc9a62d19ecd2feb4da8c3f79ef9d37"}, -    {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:bdec9ae5fd8a8d4d8813b4aac3505c027b922b4033a32a7aab66a9b2f03a7b47"}, -    {file = "rapidfuzz-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:fc668fd706ad1162ce14f26ca2957b4690d47770d23609756536c918a855ced0"}, -    {file = "rapidfuzz-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f9f35df5dd9b02669ff6b1d4a386607ff56982c86a7e57d95eb08c6afbab4ddd"}, -    {file = "rapidfuzz-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8427310ea29ce2968e1c6f6779ae5a458b3a4984f9150fc4d16f92b96456f848"}, -    {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1430dc745476e3798742ad835f61f6e6bf5d3e9a22cf9cd0288b28b7440a9872"}, -    {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1d20311da611c8f4638a09e2bc5e04b327bae010cb265ef9628d9c13c6d5da7b"}, -    {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7881965e428cf6fe248d6e702e6d5857da02278ab9b21313bee717c080e443e"}, -    {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f76c965f15861ec4d39e904bd65b84a39121334439ac17bfb8b900d1e6779a93"}, -    {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:61167f989415e701ac379de247e6b0a21ea62afc86c54d8a79f485b4f0173c02"}, -    {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:645cfb9456229f0bd5752b3eda69f221d825fbb8cbb8855433516bc185111506"}, -    {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:c28be57c9bc47b3d7f484340fab1bec8ed4393dee1090892c2774a4584435eb8"}, -    {file = "rapidfuzz-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:3c94b6d3513c693f253ff762112cc4580d3bd377e4abacb96af31a3d606fbe14"}, -    {file = "rapidfuzz-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:506d50a066451502ee2f8bf016bc3ba3e3b04eede7a4059d7956248e2dd96179"}, -    {file = "rapidfuzz-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:80b375098658bb3db14215a975d354f6573d3943ac2ae0c4627c7760d57ce075"}, -    {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ba8f7cbd8fdbd3ae115f4484888f3cb94bc2ac7cbd4eb1ca95a3d4f874261ff8"}, -    {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5fa8570720b0fdfc52f24f5663d66c52ea88ba19cb8b1ff6a39a8bc0b925b33b"}, -    {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f35c8a4c690447fd335bfd77df4da42dfea37cfa06a8ecbf22543d86dc720e12"}, -    {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:27f9eef48e212d73e78f0f5ceedc62180b68f6a25fa0752d2ccfaedc3a840bec"}, -    {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:31e99216e2a04aec4f281d472b28a683921f1f669a429cf605d11526623eaeed"}, -    {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:f22bf7ba6eddd59764457f74c637ab5c3ed976c5fcfaf827e1d320cc0478e12b"}, -    {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:c43ddb354abd00e56f024ce80affb3023fa23206239bb81916d5877cba7f2d1e"}, -    {file = "rapidfuzz-1.4.1-cp38-cp38-win32.whl", hash = "sha256:62c1f4ac20c8019ce8d481fb27235306ef3912a8d0b9a60b17905699f43ff072"}, -    {file = "rapidfuzz-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:2963f356c70b710dc6337b012ec976ce2fc2b81c2a9918a686838fead6eb4e1d"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c07f301fd549b266410654850c6918318d7dcde8201350e9ac0819f0542cf147"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa4c8b6fc7e93e3a3fb9be9566f1fe7ef920735eadcee248a0d70f3ca8941341"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c200bd813bbd3b146ba0fd284a9ad314bbad9d95ed542813273bdb9d0ee4e796"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2cccc84e1f0c6217747c09cafe93164e57d3644e18a334845a2dfbdd2073cd2c"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f2033e3d61d1e498f618123b54dc7436d50510b0d18fd678d867720e8d7b2f23"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:26b7f48b3ddd9d97cf8482a88f0f6cba47ac13ff16e63386ea7ce06178174770"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:bf18614f87fe3bfff783f0a3d0fad0eb59c92391e52555976e55570a651d2330"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:8cb5c2502ff06028a1468bdf61323b53cc3a37f54b5d62d62c5371795b81086a"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f37f80c1541d6e0a30547261900086b8c0bac519ebc12c9cd6b61a9a43a7e195"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:c13cd1e840aa93639ac1d131fbfa740a609fd20dfc2a462d5cd7bce747a2398d"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-win32.whl", hash = "sha256:0ec346f271e96c485716c091c8b0b78ba52da33f7c6ebb52a349d64094566c2d"}, -    {file = "rapidfuzz-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:5208ce1b1989a10e6fc5b5ef5d0bb7d1ffe5408838f3106abde241aff4dab08c"}, -    {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4fa195ea9ca35bacfa2a4319c6d4ab03aa6a283ad2089b70d2dfa0f6a7d9c1bc"}, -    {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:6e336cfd8103b0b38e107e01502e9d6bf7c7f04e49b970fb11a4bf6c7a932b94"}, -    {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c798c5b87efe8a7e63f408e07ff3bc03ba8b94f4498a89b48eaab3a9f439d52c"}, -    {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:bb16a10b40f5bd3c645f7748fbd36f49699a03f550c010a2c665905cc8937de8"}, -    {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2278001924031d9d75f821bff2c5fef565c8376f252562e04d8eec8857475c36"}, -    {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:a89d11f3b5da35fdf3e839186203b9367d56e2be792e8dccb098f47634ec6eb9"}, -    {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:f8c79cd11b4778d387366a59aa747f5268433f9d68be37b00d16f4fb08fdf850"}, -    {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-win32.whl", hash = "sha256:4364db793ed4b439f9dd28a335bee14e2a828283d3b93c2d2686cc645eeafdd5"}, -    {file = "rapidfuzz-1.4.1.tar.gz", hash = "sha256:de20550178376d21bfe1b34a7dc42ab107bb282ef82069cf6dfe2805a0029e26"}, +    {file = "rapidfuzz-1.7.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1ca9888e867aed2bb8d51571270e5f8393d718bb189fe1a7c0b047b8fd72bad3"}, +    {file = "rapidfuzz-1.7.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:f336cd32a2a72eb9d7694618c9065ef3a2af330ab7e54bc0ec69d3b2eb08080e"}, +    {file = "rapidfuzz-1.7.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:76124767ac3d3213a1aad989f80b156b225defef8addc825a5b631d3164c3213"}, +    {file = "rapidfuzz-1.7.1-cp27-cp27m-win32.whl", hash = "sha256:c1090deb95e5369fff47c223c0ed3472644efc56817e288ebeaaa34822a1235c"}, +    {file = "rapidfuzz-1.7.1-cp27-cp27m-win_amd64.whl", hash = "sha256:83f94c89e8f16679e0def3c7afa6c9ba477d837fd01250d6a1e3fea12267ce24"}, +    {file = "rapidfuzz-1.7.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:cdd5962bd009b1457e280b5619d312cd6305b5b8afeff6c27869f98fee839c36"}, +    {file = "rapidfuzz-1.7.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:2940960e212b66f00fc58f9b4a13e6f80221141dcbaee9c51f97e0a1f30ff1ab"}, +    {file = "rapidfuzz-1.7.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5ed4304a91043d27b92fe9af5eb87d1586548da6d03cbda5bbc98b00fee227cb"}, +    {file = "rapidfuzz-1.7.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:be18495bd84bf2bd3e888270a3cd4dea868ff4b9b8ec6e540f0e195cda554140"}, +    {file = "rapidfuzz-1.7.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:d5779e6f548b6f3edfbdfbeeda4158286684dcb2bae3515ce68c510ea48e1b4d"}, +    {file = "rapidfuzz-1.7.1-cp35-cp35m-win32.whl", hash = "sha256:80d780c4f6da08eb6801489df54fdbdc5ef2b882bd73f9585ef6e0cf09f1690d"}, +    {file = "rapidfuzz-1.7.1-cp35-cp35m-win_amd64.whl", hash = "sha256:3b205c63b8606c2b8595ba8403a8c3ebd39de9f7f44631a2f651f3efe106ae9a"}, +    {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8f96588a8a7d021debb4c60d82b15a80995daa99159bbeddd8a37f68f75ee06c"}, +    {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b8139116a937691dde17f27aafe774647808339305f4683b3a6d9bae6518aa2a"}, +    {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba574801c8410cc1f2d690ef65f898f6a660bba22ec8213e0f34dd0f0590bc71"}, +    {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d5194e3cb638af0cc7c02daa61cef07e332fd3f790ec113006302131be9afa6"}, +    {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd9d8eaae888b966422cbcba954390a63b4933d8c513ea0056fd6e42d421d08"}, +    {file = "rapidfuzz-1.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3725c61b9cf57b6b7a765b92046e7d9e5ccce845835b523954b410a70dc32692"}, +    {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e417961e5ca450d6c7448accc5a7e4e9ab0dd3c63729f76215d5e672785920fc"}, +    {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:26d756284c8c6274b5d558e759415bfb4016fcdf168159b34702c346875d8cc0"}, +    {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4887766f0dcc5df43fe4315df4b3c642829e06dc60d5bcb5e682fb76657e8ed1"}, +    {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec0a29671d59998b97998b757ab1c636dd3b7721eda41746ae897abe709681a9"}, +    {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dff55750fecd8c0f07bc199e48427c86873be2d0e6a3a80df98972847287f5d3"}, +    {file = "rapidfuzz-1.7.1-cp37-cp37m-win32.whl", hash = "sha256:e113f741bb18b0ddd14d714d80ce9c6d5322724f3023b920708e82491e7aef28"}, +    {file = "rapidfuzz-1.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ef20654be0aed240ee44c98ce02639c37422adc3e144d28c4b6d3da043d9fd20"}, +    {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9e27eb57745a4d2a390b056f6f490b712c2f54250c5d2c794dd76062065a8aef"}, +    {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:de2b0ebb67ee0b78973141dba91f574a325a3425664dbdbad37fd7aca7b28cab"}, +    {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:88c65d91dcd3c0595112d16555536c60ac5bcab1a43e517e155a242a39525057"}, +    {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:afd525a9b593cc1099f0210e116bcb4d9fc5585728d7bd929e6a4133dacd2d59"}, +    {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e6d77f104a8d67c01ae4248ced6f0d4ef05e63931afdf49c20decf962318877f"}, +    {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7db9d6ad0ab80e9e0f66f157b8e31b1d04ce5fa767b936ca1c212b98092572b1"}, +    {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0195c57f4beea0e7691594f59faf62a4be3c818c1955a8b9b712f37adc479d2d"}, +    {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ffca8c8b74d12cd36c051e9befa7c4eb2d34624ce71f22dbfc659af15bf4a1e"}, +    {file = "rapidfuzz-1.7.1-cp38-cp38-win32.whl", hash = "sha256:234cb75aa1e21cabad6a8c0718f84e2bfafdd4756b5232d5739545f97e343e59"}, +    {file = "rapidfuzz-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:058977e93ab736071fcd8828fc6289ec026e9ca4a19f2a0967f9260e63910da8"}, +    {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9d02bb0724326826b1884cc9b9d9fd97ac352c18213f45e465a39ef069a33115"}, +    {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:212d6fa5b824aaa49a921c81d7cdc1d079b3545a30563ae14dc88e17918e76bf"}, +    {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a0cd8117deba10e2a1d6dccb6ff44a4c737adda3048dc45860c5f53cf64db14f"}, +    {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:61faa47b6b5d5a0cbe9fa6369df44d3f9435c4cccdb4d38d9de437f18b69dc4d"}, +    {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1daa756be52a7ee60d553ba667cda3a188ee811c92a9c21df43a4cdadb1eb8ca"}, +    {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c98ac10782dadf507e922963c8b8456a79151b4f10dbb08cfc86c1572db366dc"}, +    {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:358d80061ca107df6c3e1f67fa7af0f94a62827cb9c44ac09a16e78b38f7c3d5"}, +    {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5f90fc31d54fcd74a97d175892555786a8214a3cff43077463915b8a45a191d"}, +    {file = "rapidfuzz-1.7.1-cp39-cp39-win32.whl", hash = "sha256:55dffdcdccea6f077a4f09164039411f01f621633be5883c58ceaf94f007a688"}, +    {file = "rapidfuzz-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:d712a7f680d2074b587650f81865ca838c04fcc6b77c9d2d742de0853aaa24ce"}, +    {file = "rapidfuzz-1.7.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:729d73a8db5a2b444a19d4aa2be009b2e628d207d7c754f6d280e3c6a59b94cb"}, +    {file = "rapidfuzz-1.7.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:a1cabbc645395b6175cad79164d9ec621866a004b476e44cac534020b9f6bddb"}, +    {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ae697294f456f7f76e5bd30db5a65e8b855e7e09f9a65e144efa1e2c5009553c"}, +    {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e8ae51c1cf1f034f15216fec2e1eef658c8b3a9cbdcc1a053cc7133ede9d616d"}, +    {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:dccc072f2a0eeb98d46a79427ef793836ebc5184b1fe544b34607be10705ddc3"}, +    {file = "rapidfuzz-1.7.1.tar.gz", hash = "sha256:99495c679174b2a02641f7dc2364a208135cacca77fc4825a86efbfe1e23b0ff"},  ]  redis = [      {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, @@ -1793,8 +1896,8 @@ requests = [      {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},  ]  sentry-sdk = [ -    {file = "sentry-sdk-1.3.1.tar.gz", hash = "sha256:ebe99144fa9618d4b0e7617e7929b75acd905d258c3c779edcd34c0adfffe26c"}, -    {file = "sentry_sdk-1.3.1-py2.py3-none-any.whl", hash = "sha256:f33d34c886d0ba24c75ea8885a8b3a172358853c7cbde05979fc99c29ef7bc52"}, +    {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"},  ]  sgmllib3k = [      {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, @@ -1823,59 +1926,98 @@ taskipy = [      {file = "taskipy-1.7.0-py3-none-any.whl", hash = "sha256:9e284c10898e9dee01a3e72220b94b192b1daa0f560271503a6df1da53d03844"},      {file = "taskipy-1.7.0.tar.gz", hash = "sha256:960e480b1004971e76454ecd1a0484e640744a30073a1069894a311467f85ed8"},  ] +testfixtures = [ +    {file = "testfixtures-6.18.3-py2.py3-none-any.whl", hash = "sha256:6ddb7f56a123e1a9339f130a200359092bd0a6455e31838d6c477e8729bb7763"}, +    {file = "testfixtures-6.18.3.tar.gz", hash = "sha256:2600100ae96ffd082334b378e355550fef8b4a529a6fa4c34f47130905c7426d"}, +]  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.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, -    {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, -    {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, +    {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"},  ]  urllib3 = [ -    {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, -    {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, +    {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, +    {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"},  ]  virtualenv = [ -    {file = "virtualenv-20.7.0-py2.py3-none-any.whl", hash = "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"}, -    {file = "virtualenv-20.7.0.tar.gz", hash = "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094"}, +    {file = "virtualenv-20.8.1-py2.py3-none-any.whl", hash = "sha256:10062e34c204b5e4ec5f62e6ef2473f8ba76513a9a617e873f1f8fb4a519d300"}, +    {file = "virtualenv-20.8.1.tar.gz", hash = "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8"},  ]  yarl = [ -    {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, -    {file = "yarl-1.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478"}, -    {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6"}, -    {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e"}, -    {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406"}, -    {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76"}, -    {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366"}, -    {file = "yarl-1.6.3-cp36-cp36m-win32.whl", hash = "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721"}, -    {file = "yarl-1.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643"}, -    {file = "yarl-1.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e"}, -    {file = "yarl-1.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3"}, -    {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8"}, -    {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a"}, -    {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c"}, -    {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f"}, -    {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970"}, -    {file = "yarl-1.6.3-cp37-cp37m-win32.whl", hash = "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e"}, -    {file = "yarl-1.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50"}, -    {file = "yarl-1.6.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2"}, -    {file = "yarl-1.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec"}, -    {file = "yarl-1.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"}, -    {file = "yarl-1.6.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc"}, -    {file = "yarl-1.6.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959"}, -    {file = "yarl-1.6.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2"}, -    {file = "yarl-1.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2"}, -    {file = "yarl-1.6.3-cp38-cp38-win32.whl", hash = "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896"}, -    {file = "yarl-1.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a"}, -    {file = "yarl-1.6.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e"}, -    {file = "yarl-1.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724"}, -    {file = "yarl-1.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c"}, -    {file = "yarl-1.6.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25"}, -    {file = "yarl-1.6.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96"}, -    {file = "yarl-1.6.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0"}, -    {file = "yarl-1.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4"}, -    {file = "yarl-1.6.3-cp39-cp39-win32.whl", hash = "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424"}, -    {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"}, -    {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"}, +    {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"},  ] diff --git a/pyproject.toml b/pyproject.toml index 2ae79f9e4..e227ffaa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ 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" @@ -17,7 +18,6 @@ beautifulsoup4 = "~=4.9"  colorama = { version = "~=0.4.3", markers = "sys_platform == 'win32'" }  coloredlogs = "~=14.0"  deepdiff = "~=4.0" -"discord.py" = "~=1.7.3"  emoji = "~=0.6"  feedparser = "~=6.0.2"  rapidfuzz = "~=1.4" @@ -38,13 +38,14 @@ flake8 = "~=3.8"  flake8-annotations = "~=2.0"  flake8-bugbear = "~=20.1"  flake8-docstrings = "~=1.4" -flake8-import-order = "~=0.18"  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.2"  python-dotenv = "~=0.17.1"  pytest = "~=6.2.4"  pytest-cov = "~=2.12.1" @@ -61,11 +62,21 @@ precommit = "pre-commit install"  build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ."  push = "docker push ghcr.io/python-discord/bot:latest"  test-nocov = "pytest -n auto" -test = "pytest -n auto --cov-report= --cov" +test = "pytest -n auto --cov-report= --cov --ff" +retest = "pytest -n auto --cov-report= --cov --lf"  html = "coverage html"  report = "coverage report" +isort = "isort ."  [tool.coverage.run]  branch = true  source_pkgs = ["bot"]  source = ["tests"] + +[tool.isort] +multi_line_output = 6 +order_by_type = false +case_sensitive = true +combine_as_imports = true +line_length = 120 +atomic = true diff --git a/tests/__init__.py b/tests/__init__.py index 2228110ad..c2b9d12dc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,6 @@  import logging +from bot.log import get_logger -log = logging.getLogger() +log = get_logger()  log.setLevel(logging.CRITICAL) diff --git a/tests/base.py b/tests/base.py index d99b9ac31..5e304ea9d 100644 --- a/tests/base.py +++ b/tests/base.py @@ -6,6 +6,7 @@ from typing import Dict  import discord  from discord.ext import commands +from bot.log import get_logger  from tests import helpers @@ -42,7 +43,7 @@ class LoggingTestsMixin:          manager when we're testing under the assumption that no log records will be emitted.          """          if not isinstance(logger, logging.Logger): -            logger = logging.getLogger(logger) +            logger = get_logger(logger)          if level:              level = logging._nameToLevel.get(level, level) @@ -102,4 +103,4 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase):          with self.assertRaises(commands.MissingPermissions) as cm:              await cmd.can_run(ctx) -        self.assertCountEqual(permissions.keys(), cm.exception.missing_perms) +        self.assertCountEqual(permissions.keys(), cm.exception.missing_permissions) diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py index 3ad9db9c3..9dc46005b 100644 --- a/tests/bot/exts/backend/sync/test_base.py +++ b/tests/bot/exts/backend/sync/test_base.py @@ -1,7 +1,6 @@  import unittest  from unittest import mock -  from bot.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 22a07313e..fdd0ab74a 100644 --- a/tests/bot/exts/backend/sync/test_cog.py +++ b/tests/bot/exts/backend/sync/test_cog.py @@ -60,13 +60,13 @@ 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): +    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.          self.RoleSyncer.reset_mock()          self.UserSyncer.reset_mock() -        self.bot.loop.create_task = mock.MagicMock()          mock_sync_guild_coro = mock.MagicMock()          sync_guild.return_value = mock_sync_guild_coro @@ -74,7 +74,8 @@ class SyncCogTests(SyncCogTestCase):          Sync(self.bot)          sync_guild.assert_called_once_with() -        self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) +        create_task.assert_called_once() +        self.assertEqual(create_task.call_args.args[0], mock_sync_guild_coro)      async def test_sync_cog_sync_guild(self):          """Roles and users should be synced only if a guild is successfully retrieved.""" diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index 27932be95..2fc97af2d 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -1,6 +1,8 @@  import unittest  from unittest import mock +from discord.errors import NotFound +  from bot.exts.backend.sync._syncers import UserSyncer, _Diff  from tests import helpers @@ -10,7 +12,7 @@ def fake_user(**kwargs):      kwargs.setdefault("id", 43)      kwargs.setdefault("name", "bob the test man")      kwargs.setdefault("discriminator", 1337) -    kwargs.setdefault("roles", [666]) +    kwargs.setdefault("roles", [helpers.MockRole(id=666)])      kwargs.setdefault("in_guild", True)      return kwargs @@ -134,6 +136,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):              self.get_mock_member(fake_user()),              None          ] +        guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found")          actual_diff = await UserSyncer._get_diff(guild)          expected_diff = ([], [{"id": 63, "in_guild": False}], None) @@ -158,6 +161,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):              self.get_mock_member(updated_user),              None          ] +        guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found")          actual_diff = await UserSyncer._get_diff(guild)          expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None) @@ -177,6 +181,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):              self.get_mock_member(fake_user()),              None          ] +        guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found")          actual_diff = await UserSyncer._get_diff(guild)          expected_diff = ([], [], None) diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 2b0549b98..462f718e6 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -107,7 +107,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase):          """Should send error with `ctx.send` when error is `CommandOnCooldown`."""          self.ctx.reset_mock()          cog = ErrorHandler(self.bot) -        error = errors.CommandOnCooldown(10, 9) +        error = errors.CommandOnCooldown(10, 9, type=None)          self.assertIsNone(await cog.on_command_error(self.ctx, error))          self.ctx.send.assert_awaited_once_with(error) diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py index b9ee1e363..0856546af 100644 --- a/tests/bot/exts/events/test_code_jams.py +++ b/tests/bot/exts/events/test_code_jams.py @@ -8,8 +8,8 @@ from bot.constants import Roles  from bot.exts.events import code_jams  from bot.exts.events.code_jams import _channels, _cog  from tests.helpers import ( -    MockAttachment, MockBot, MockCategoryChannel, MockContext, -    MockGuild, MockMember, MockRole, MockTextChannel, autospec +    MockAttachment, MockBot, MockCategoryChannel, MockContext, MockGuild, MockMember, MockRole, MockTextChannel, +    autospec  )  TEST_CSV = b"""\ diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 51feae9cb..4db27269a 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -26,7 +26,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          self.msg.guild.get_member.return_value.bot = False          self.msg.guild.get_member.return_value.__str__.return_value = "Woody"          self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) -        self.msg.author.avatar_url_as.return_value = "picture-lemon.png" +        self.msg.author.display_avatar.url = "picture-lemon.png"      def test_extract_user_id_valid(self):          """Should consider user IDs valid if they decode into an integer ID.""" @@ -295,20 +295,21 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          )      @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE") -    def test_format_userid_log_message_unknown(self, unknown_user_log_message): +    async def test_format_userid_log_message_unknown(self, unknown_user_log_message,):          """Should correctly format the user ID portion when the actual user it belongs to is unknown."""          token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")          unknown_user_log_message.format.return_value = " Partner"          msg = MockMessage(id=555, content="hello world")          msg.guild.get_member.return_value = None +        msg.guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found") -        return_value = TokenRemover.format_userid_log_message(msg, token) +        return_value = await TokenRemover.format_userid_log_message(msg, token)          self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False))          unknown_user_log_message.format.assert_called_once_with(user_id=472265943062413332)      @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") -    def test_format_userid_log_message_bot(self, known_user_log_message): +    async def test_format_userid_log_message_bot(self, known_user_log_message):          """Should correctly format the user ID portion when the ID belongs to a known bot."""          token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")          known_user_log_message.format.return_value = " Partner" @@ -316,7 +317,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          msg.guild.get_member.return_value.__str__.return_value = "Sam"          msg.guild.get_member.return_value.bot = True -        return_value = TokenRemover.format_userid_log_message(msg, token) +        return_value = await TokenRemover.format_userid_log_message(msg, token)          self.assertEqual(return_value, (known_user_log_message.format.return_value, True)) @@ -327,12 +328,12 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          )      @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") -    def test_format_log_message_user_token_user(self, user_token_message): +    async def test_format_log_message_user_token_user(self, user_token_message):          """Should correctly format the user ID portion when the ID belongs to a known user."""          token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")          user_token_message.format.return_value = "Partner" -        return_value = TokenRemover.format_userid_log_message(self.msg, token) +        return_value = await TokenRemover.format_userid_log_message(self.msg, token)          self.assertEqual(return_value, (user_token_message.format.return_value, True))          user_token_message.format.assert_called_once_with( @@ -375,7 +376,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):              colour=Colour(constants.Colours.soft_red),              title="Token removed!",              text=log_msg + "\n" + userid_log_message, -            thumbnail=self.msg.author.avatar_url_as.return_value, +            thumbnail=self.msg.author.display_avatar.url,              channel_id=constants.Channels.mod_alerts,              ping_everyone=True,          ) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d8250befb..4b50c3fd9 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -84,7 +84,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase):          self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id))          self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") -        self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") +        self.assertEqual(dummy_embed.fields[2].value, "0.65 0.64 242")          self.assertEqual(dummy_embed.fields[3].value, "1")          self.assertEqual(dummy_embed.fields[4].value, "10")          self.assertEqual(dummy_embed.fields[5].value, "0") @@ -435,10 +435,9 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          ctx = helpers.MockContext()          user = helpers.MockMember(id=217, colour=0) -        user.avatar_url_as.return_value = "avatar url" +        user.display_avatar.url = "avatar url"          embed = await self.cog.create_user_embed(ctx, user) -        user.avatar_url_as.assert_called_once_with(static_format="png")          self.assertEqual(embed.thumbnail.url, "avatar url") diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index f844a9181..4d01e18a5 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -3,6 +3,8 @@ import textwrap  import unittest  from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from discord.errors import NotFound +  from bot.constants import Event  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction.infractions import Infractions @@ -13,12 +15,13 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):      """Tests for ban and kick command reason truncation."""      def setUp(self): +        self.me = MockMember(id=7890, roles=[MockRole(id=7890, position=5)])          self.bot = MockBot()          self.cog = Infractions(self.bot) -        self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10)) -        self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0)) +        self.user = MockMember(id=1234, roles=[MockRole(id=3577, position=10)]) +        self.target = MockMember(id=1265, roles=[MockRole(id=9876, position=1)])          self.guild = MockGuild(id=4567) -        self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) +        self.ctx = MockContext(me=self.me, bot=self.bot, author=self.user, guild=self.guild)      @patch("bot.exts.moderation.infraction._utils.get_active_infraction")      @patch("bot.exts.moderation.infraction._utils.post_infraction") @@ -64,8 +67,8 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):      def setUp(self):          self.bot = MockBot() -        self.mod = MockMember(top_role=10) -        self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) +        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) @@ -195,6 +198,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):      async def test_voice_unban_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)          self.assertEqual(result, {"Info": "User was not found in the guild."}) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index eb256f1fd..72eebb254 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -139,14 +139,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                          type="Ban",                          expires="2020-02-26 09:20 (23 hours and 59 minutes)",                          reason="No reason provided." -                    ), +                    ) + utils.INFRACTION_APPEAL_SERVER_FOOTER,                      colour=Colours.soft_red,                      url=utils.RULES_URL                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL,                      icon_url=Icons.token_removed -                ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), +                ),                  "send_result": True              },              { @@ -157,14 +157,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                          type="Warning",                          expires="N/A",                          reason="Test reason." -                    ), +                    ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER,                      colour=Colours.soft_red,                      url=utils.RULES_URL                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL,                      icon_url=Icons.token_removed -                ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), +                ),                  "send_result": False              },              # Note that this test case asserts that the DM that *would* get sent to the user is formatted @@ -177,14 +177,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                          type="Note",                          expires="N/A",                          reason="No reason provided." -                    ), +                    ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER,                      colour=Colours.soft_red,                      url=utils.RULES_URL                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL,                      icon_url=Icons.defcon_denied -                ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), +                ),                  "send_result": False              },              { @@ -195,14 +195,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                          type="Mute",                          expires="2020-02-26 09:20 (23 hours and 59 minutes)",                          reason="Test" -                    ), +                    ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER,                      colour=Colours.soft_red,                      url=utils.RULES_URL                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL,                      icon_url=Icons.defcon_denied -                ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), +                ),                  "send_result": False              },              { @@ -213,14 +213,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                          type="Mute",                          expires="N/A",                          reason="foo bar" * 4000 -                    )[:4093] + "...", +                    )[:4093-utils.LONGEST_EXTRAS] + "..." + utils.INFRACTION_APPEAL_MODMAIL_FOOTER,                      colour=Colours.soft_red,                      url=utils.RULES_URL                  ).set_author(                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL,                      icon_url=Icons.defcon_denied -                ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), +                ),                  "send_result": True              }          ] diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index cbf7f7bcf..ccc842050 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -3,7 +3,7 @@ import enum  import logging  import typing as t  import unittest -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch  import aiohttp  import discord @@ -11,15 +11,8 @@ import discord  from bot.constants import Colours  from bot.exts.moderation import incidents  from tests.helpers import ( -    MockAsyncWebhook, -    MockAttachment, -    MockBot, -    MockMember, -    MockMessage, -    MockReaction, -    MockRole, -    MockTextChannel, -    MockUser, +    MockAsyncWebhook, MockAttachment, MockBot, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel, +    MockUser  ) @@ -379,7 +372,7 @@ class TestArchive(TestIncidents):          # Define our own `incident` to be archived          incident = MockMessage(              content="this is an incident", -            author=MockUser(name="author_name", avatar_url="author_avatar"), +            author=MockUser(name="author_name", display_avatar=Mock(url="author_avatar")),              id=123,          )          built_embed = MagicMock(discord.Embed, id=123)  # We patch `make_embed` to return this diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 59a5893ef..92ce3418a 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -12,14 +12,7 @@ from discord import PermissionOverwrite  from bot.constants import Channels, Guild, MODERATION_ROLES, Roles  from bot.exts.moderation import silence  from tests.helpers import ( -    MockBot, -    MockContext, -    MockGuild, -    MockMember, -    MockRole, -    MockTextChannel, -    MockVoiceChannel, -    autospec +    MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel, MockVoiceChannel, autospec  )  redis_session = None @@ -438,7 +431,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):          asyncio.run(self.cog._async_init())  # Populate instance attributes.          self.text_channel = MockTextChannel() -        self.text_overwrite = PermissionOverwrite(send_messages=True, add_reactions=False) +        self.text_overwrite = PermissionOverwrite( +            send_messages=True, +            add_reactions=False, +            create_private_threads=True, +            create_public_threads=False, +            send_messages_in_threads=True +        )          self.text_channel.overwrites_for.return_value = self.text_overwrite          self.voice_channel = MockVoiceChannel() @@ -509,9 +508,39 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):      async def test_skipped_already_silenced(self):          """Permissions were not set and `False` was returned for an already silenced channel."""          subtests = ( -            (False, MockTextChannel(), PermissionOverwrite(send_messages=False, add_reactions=False)), -            (True, MockTextChannel(), PermissionOverwrite(send_messages=True, add_reactions=True)), -            (True, MockTextChannel(), PermissionOverwrite(send_messages=False, add_reactions=False)), +            ( +                False, +                MockTextChannel(), +                PermissionOverwrite( +                    send_messages=False, +                    add_reactions=False, +                    create_private_threads=False, +                    create_public_threads=False, +                    send_messages_in_threads=False +                ) +            ), +            ( +                True, +                MockTextChannel(), +                PermissionOverwrite( +                    send_messages=True, +                    add_reactions=True, +                    create_private_threads=True, +                    create_public_threads=True, +                    send_messages_in_threads=True +                ) +            ), +            ( +                True, +                MockTextChannel(), +                PermissionOverwrite( +                    send_messages=False, +                    add_reactions=False, +                    create_private_threads=False, +                    create_public_threads=False, +                    send_messages_in_threads=False +                ) +            ),              (False, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)),              (True, MockVoiceChannel(), PermissionOverwrite(connect=True, speak=True)),              (True, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)), @@ -559,11 +588,16 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):          await self.cog._set_silence_overwrites(self.text_channel)          new_overwrite_dict = dict(self.text_overwrite) -        # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. -        del prev_overwrite_dict['send_messages'] -        del prev_overwrite_dict['add_reactions'] -        del new_overwrite_dict['send_messages'] -        del new_overwrite_dict['add_reactions'] +        # Remove related permission keys because they were changed by the method. +        for perm_name in ( +                "send_messages", +                "add_reactions", +                "create_private_threads", +                "create_public_threads", +                "send_messages_in_threads" +        ): +            del prev_overwrite_dict[perm_name] +            del new_overwrite_dict[perm_name]          self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) @@ -601,7 +635,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):      async def test_cached_previous_overwrites(self):          """Channel's previous overwrites were cached.""" -        overwrite_json = '{"send_messages": true, "add_reactions": false}' +        overwrite_json = ( +            '{"send_messages": true, "add_reactions": false, "create_private_threads": true, ' +            '"create_public_threads": false, "send_messages_in_threads": true}' +        )          await self.cog._set_silence_overwrites(self.text_channel)          self.cog.previous_overwrites.set.assert_awaited_once_with(self.text_channel.id, overwrite_json) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 6e3a6b898..ef6c8e19e 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -6,13 +6,7 @@ from unittest.mock import MagicMock, patch  from dateutil.relativedelta import relativedelta  from discord.ext.commands import BadArgument -from bot.converters import ( -    Duration, -    HushDurationConverter, -    ISODateTime, -    PackageName, -    TagNameConverter, -) +from bot.converters import Duration, HushDurationConverter, ISODateTime, PackageName, TagNameConverter  class ConverterTests(unittest.IsolatedAsyncioTestCase): diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index 883465e0b..4ae11d5d3 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -32,6 +32,7 @@ class ChecksTests(unittest.IsolatedAsyncioTestCase):      async def test_has_no_roles_check_without_guild(self):          """`has_no_roles_check` should return `False` when `Context.guild` is None."""          self.ctx.channel = MagicMock(DMChannel) +        self.ctx.guild = None          self.assertFalse(await checks.has_no_roles_check(self.ctx))      async def test_has_no_roles_check_returns_false_with_unwanted_role(self): diff --git a/tests/helpers.py b/tests/helpers.py index 3978076ed..9d4988d23 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -39,7 +39,7 @@ class HashableMixin(discord.mixins.EqualityComparable):  class ColourMixin: -    """A mixin for Mocks that provides the aliasing of color->colour like discord.py does.""" +    """A mixin for Mocks that provides the aliasing of (accent_)color->(accent_)colour like discord.py does."""      @property      def color(self) -> discord.Colour: @@ -49,6 +49,14 @@ class ColourMixin:      def color(self, color: discord.Colour) -> None:          self.colour = color +    @property +    def accent_color(self) -> discord.Colour: +        return self.accent_colour + +    @accent_color.setter +    def accent_color(self, color: discord.Colour) -> None: +        self.accent_colour = color +  class CustomMockMixin:      """ @@ -235,13 +243,20 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin          self.roles = [MockRole(name="@everyone", position=1, id=0)]          if roles:              self.roles.extend(roles) +        self.top_role = max(self.roles)          if 'mention' not in kwargs:              self.mention = f"@{self.name}"  # Create a User instance to get a realistic Mock of `discord.User` -user_instance = discord.User(data=unittest.mock.MagicMock(), state=unittest.mock.MagicMock()) +_user_data_mock = collections.defaultdict(unittest.mock.MagicMock, { +    "accent_color": 0 +}) +user_instance = discord.User( +    data=unittest.mock.MagicMock(get=unittest.mock.Mock(side_effect=_user_data_mock.get)), +    state=unittest.mock.MagicMock() +)  class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): @@ -278,7 +293,10 @@ def _get_mock_loop() -> unittest.mock.Mock:      # Since calling `create_task` on our MockBot does not actually schedule the coroutine object      # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object      # to prevent "has not been awaited"-warnings. -    loop.create_task.side_effect = lambda coroutine: coroutine.close() +    def mock_create_task(coroutine, **kwargs): +        coroutine.close() +        return unittest.mock.Mock() +    loop.create_task.side_effect = mock_create_task      return loop @@ -424,7 +442,12 @@ message_instance = discord.Message(state=state, channel=channel, data=message_da  # Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` -context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) +context_instance = Context( +    message=unittest.mock.MagicMock(), +    prefix="$", +    bot=MockBot(), +    view=None +)  context_instance.invoked_from_error_handler = None @@ -439,6 +462,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock):      def __init__(self, **kwargs) -> None:          super().__init__(**kwargs) +        self.me = kwargs.get('me', MockMember())          self.bot = kwargs.get('bot', MockBot())          self.guild = kwargs.get('guild', MockGuild())          self.author = kwargs.get('author', MockMember()) @@ -532,7 +556,7 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock):          self.__str__.return_value = str(self.emoji) -webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock()) +webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), session=unittest.mock.MagicMock())  class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock): diff --git a/tests/test_base.py b/tests/test_base.py index a7db4bf3e..365805a71 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,8 +1,7 @@  import logging -import unittest  import unittest.mock - +from bot.log import get_logger  from tests.base import LoggingTestsMixin, _CaptureLogHandler @@ -15,7 +14,7 @@ class LoggingTestCaseTests(unittest.TestCase):      @classmethod      def setUpClass(cls): -        cls.log = logging.getLogger(__name__) +        cls.log = get_logger(__name__)      def test_assert_not_logs_does_not_raise_with_no_logs(self):          """Test if LoggingTestCase.assertNotLogs does not raise when no logs were emitted.""" @@ -56,15 +55,15 @@ class LoggingTestCaseTests(unittest.TestCase):      def test_logging_test_case_works_with_logger_instance(self):          """Test if the LoggingTestCase captures logging for provided logger.""" -        log = logging.getLogger("new_logger") +        log = get_logger("new_logger")          with self.assertRaises(AssertionError):              with LoggingTestCase.assertNotLogs(self, logger=log):                  log.info("Hello, this should raise an AssertionError")      def test_logging_test_case_respects_alternative_logger(self):          """Test if LoggingTestCase only checks the provided logger.""" -        log_one = logging.getLogger("log one") -        log_two = logging.getLogger("log two") +        log_one = get_logger("log one") +        log_two = get_logger("log two")          with LoggingTestCase.assertNotLogs(self, logger=log_one):              log_two.info("Hello, this should not raise an AssertionError") @@ -5,7 +5,7 @@ import-order-style=pycharm  application_import_names=bot,tests  exclude=.cache,.venv,.git,constants.py  ignore= -    B311,W503,E226,S311,T000 +    B311,W503,E226,S311,T000,E731      # Missing Docstrings      D100,D104,D105,D107,      # Docstring Whitespace | 
