aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar ToxicKidz <[email protected]>2021-07-05 20:55:43 -0400
committerGravatar GitHub <[email protected]>2021-07-05 20:55:43 -0400
commit646b84017810de48201355957e5067ffe5b98b38 (patch)
tree8ee4991aa16dbcdaf4d2d42ac503a0be4ba6e807
parentchore: Change the `Code Jam Team Leader` role's name (diff)
parentAdds Documentation For Running A Single Test (#1669) (diff)
Merge branch 'main' into feat/code-jam-channels-automation
-rw-r--r--.coveragerc5
-rw-r--r--.github/workflows/lint-test.yml6
-rw-r--r--bot/constants.py3
-rw-r--r--bot/exts/filters/antimalware.py11
-rw-r--r--bot/exts/filters/filtering.py28
-rw-r--r--bot/exts/help_channels/_caches.py9
-rw-r--r--bot/exts/help_channels/_cog.py127
-rw-r--r--bot/exts/help_channels/_cooldown.py95
-rw-r--r--bot/exts/help_channels/_message.py40
-rw-r--r--bot/exts/info/doc/_cog.py37
-rw-r--r--bot/exts/info/information.py5
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py31
-rw-r--r--bot/exts/moderation/infraction/_utils.py2
-rw-r--r--bot/exts/moderation/voice_gate.py47
-rw-r--r--bot/exts/moderation/watchchannels/bigbrother.py5
-rw-r--r--bot/exts/recruitment/talentpool/_review.py25
-rw-r--r--bot/exts/utils/bot.py2
-rw-r--r--bot/exts/utils/utils.py3
-rw-r--r--bot/pagination.py13
-rw-r--r--bot/resources/tags/async-await.md28
-rw-r--r--bot/resources/tags/dunder-methods.md28
-rw-r--r--bot/resources/tags/floats.md2
-rw-r--r--bot/resources/tags/modmail.md2
-rw-r--r--bot/resources/tags/star-imports.md9
-rw-r--r--bot/utils/messages.py2
-rw-r--r--bot/utils/regex.py1
-rw-r--r--bot/utils/scheduling.py19
-rw-r--r--config-default.yml13
-rw-r--r--docker-compose.yml4
-rw-r--r--poetry.lock345
-rw-r--r--pyproject.toml13
-rw-r--r--tests/README.md40
-rw-r--r--tests/bot/exts/filters/test_antimalware.py45
-rw-r--r--tests/bot/exts/moderation/infraction/test_utils.py4
34 files changed, 710 insertions, 339 deletions
diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index d572bd705..000000000
--- a/.coveragerc
+++ /dev/null
@@ -1,5 +0,0 @@
-[run]
-branch = true
-source =
- bot
- tests
diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml
index d96f324ec..e99e6d181 100644
--- a/.github/workflows/lint-test.yml
+++ b/.github/workflows/lint-test.yml
@@ -97,12 +97,8 @@ jobs:
--format='::error file=%(path)s,line=%(row)d,col=%(col)d::\
[flake8] %(code)s: %(text)s'"
- # We run `coverage` using the `python` command so we can suppress
- # irrelevant warnings in our CI output.
- name: Run tests and generate coverage report
- run: |
- python -Wignore -m coverage run -m unittest
- coverage report -m
+ 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
diff --git a/bot/constants.py b/bot/constants.py
index f33c14798..f1c9c2c32 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -435,6 +435,8 @@ class Channels(metaclass=YAMLGetter):
off_topic_1: int
off_topic_2: int
+ black_formatter: int
+
bot_commands: int
discord_py: int
esoteric: int
@@ -606,7 +608,6 @@ class HelpChannels(metaclass=YAMLGetter):
section = 'help_channels'
enable: bool
- claim_minutes: int
cmd_whitelist: List[int]
idle_minutes_claimant: int
idle_minutes_others: int
diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py
index 26f00e91f..89e539e7b 100644
--- a/bot/exts/filters/antimalware.py
+++ b/bot/exts/filters/antimalware.py
@@ -15,9 +15,11 @@ PY_EMBED_DESCRIPTION = (
f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}"
)
+TXT_LIKE_FILES = {".txt", ".csv", ".json"}
TXT_EMBED_DESCRIPTION = (
"**Uh-oh!** It looks like your message got zapped by our spam filter. "
- "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n"
+ "We currently don't allow `{blocked_extension}` attachments, "
+ "so here are some tips to help you travel safely: \n\n"
"• If you attempted to send a message longer than 2000 characters, try shortening your message "
"to fit within the character limit or use a pasting service (see below) \n\n"
"• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in "
@@ -70,10 +72,13 @@ class AntiMalware(Cog):
if ".py" in extensions_blocked:
# Short-circuit on *.py files to provide a pastebin link
embed.description = PY_EMBED_DESCRIPTION
- elif ".txt" in extensions_blocked:
+ elif extensions := TXT_LIKE_FILES.intersection(extensions_blocked):
# Work around Discord AutoConversion of messages longer than 2000 chars to .txt
cmd_channel = self.bot.get_channel(Channels.bot_commands)
- embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention)
+ embed.description = TXT_EMBED_DESCRIPTION.format(
+ blocked_extension=extensions.pop(),
+ cmd_channel_mention=cmd_channel.mention
+ )
elif extensions_blocked:
meta_channel = self.bot.get_channel(Channels.meta)
embed.description = DISALLOWED_EMBED_DESCRIPTION.format(
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 464732453..661d6c9a2 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -103,19 +103,6 @@ class Filtering(Cog):
),
"schedule_deletion": False
},
- "filter_everyone_ping": {
- "enabled": Filter.filter_everyone_ping,
- "function": self._has_everyone_ping,
- "type": "filter",
- "content_only": True,
- "user_notification": Filter.notify_user_everyone_ping,
- "notification_msg": (
- "Please don't try to ping `@everyone` or `@here`. "
- f"Your message has been removed. {staff_mistake_str}"
- ),
- "schedule_deletion": False,
- "ping_everyone": False
- },
"watch_regex": {
"enabled": Filter.watch_regex,
"function": self._has_watch_regex_match,
@@ -129,7 +116,20 @@ class Filtering(Cog):
"type": "watchlist",
"content_only": False,
"schedule_deletion": False
- }
+ },
+ "filter_everyone_ping": {
+ "enabled": Filter.filter_everyone_ping,
+ "function": self._has_everyone_ping,
+ "type": "filter",
+ "content_only": True,
+ "user_notification": Filter.notify_user_everyone_ping,
+ "notification_msg": (
+ "Please don't try to ping `@everyone` or `@here`. "
+ f"Your message has been removed. {staff_mistake_str}"
+ ),
+ "schedule_deletion": False,
+ "ping_everyone": False
+ },
}
self.bot.loop.create_task(self.reschedule_offensive_msg_deletion())
diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py
index c5e4ee917..8d45c2466 100644
--- a/bot/exts/help_channels/_caches.py
+++ b/bot/exts/help_channels/_caches.py
@@ -24,3 +24,12 @@ question_messages = RedisCache(namespace="HelpChannels.question_messages")
# This cache keeps track of the dynamic message ID for
# the continuously updated message in the #How-to-get-help channel.
dynamic_message = RedisCache(namespace="HelpChannels.dynamic_message")
+
+# This cache keeps track of who has help-dms on.
+# RedisCache[discord.User.id, bool]
+help_dm = RedisCache(namespace="HelpChannels.help_dm")
+
+# This cache tracks member who are participating and opted in to help channel dms.
+# serialise the set as a comma separated string to allow usage with redis
+# RedisCache[discord.TextChannel.id, str[set[discord.User.id]]]
+session_participants = RedisCache(namespace="HelpChannels.session_participants")
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 262b18e16..35658d117 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -12,7 +12,8 @@ from discord.ext import commands
from bot import constants
from bot.bot import Bot
-from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats
+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
log = logging.getLogger(__name__)
@@ -94,6 +95,24 @@ class HelpChannels(commands.Cog):
self.scheduler.cancel_all()
+ async def _handle_role_change(self, member: discord.Member, coro: t.Callable[..., t.Coroutine]) -> None:
+ """
+ Change `member`'s cooldown role via awaiting `coro` and handle errors.
+
+ `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
+ """
+ try:
+ await coro(self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown))
+ except discord.NotFound:
+ log.debug(f"Failed to change role for {member} ({member.id}): member not found")
+ except discord.Forbidden:
+ log.debug(
+ f"Forbidden to change role for {member} ({member.id}); "
+ f"possibly due to role hierarchy"
+ )
+ except discord.HTTPException as e:
+ log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")
+
@lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id"))
@lock.lock_arg(NAMESPACE, "message", attrgetter("author.id"))
@lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True)
@@ -106,9 +125,10 @@ class HelpChannels(commands.Cog):
"""
log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.")
await self.move_to_in_use(message.channel)
- await _cooldown.revoke_send_permissions(message.author, self.scheduler)
+ await self._handle_role_change(message.author, message.author.add_roles)
await _message.pin(message)
+
try:
await _message.dm_on_open(message)
except Exception as e:
@@ -276,7 +296,6 @@ class HelpChannels(commands.Cog):
log.trace("Initialising the cog.")
await self.init_categories()
- await _cooldown.check_cooldowns(self.scheduler)
self.channel_queue = self.create_channel_queue()
self.name_queue = _name.create_name_queue(
@@ -406,17 +425,13 @@ class HelpChannels(commands.Cog):
) -> None:
"""Actual implementation of `unclaim_channel`. See that for full documentation."""
await _caches.claimants.delete(channel.id)
-
- # Ignore missing tasks because a channel may still be dormant after the cooldown expires.
- if claimant_id in self.scheduler:
- self.scheduler.cancel(claimant_id)
+ await _caches.session_participants.delete(channel.id)
claimant = self.bot.get_guild(constants.Guild.id).get_member(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")
- elif not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()):
- # Remove the cooldown role if the claimant has no other channels left
- await _cooldown.remove_cooldown_role(claimant)
+ else:
+ await self._handle_role_change(claimant, claimant.remove_roles)
await _message.unpin(channel)
await _stats.report_complete_session(channel.id, closed_on)
@@ -453,7 +468,9 @@ class HelpChannels(commands.Cog):
if channel_utils.is_in_category(message.channel, constants.Categories.help_available):
if not _channel.is_excluded_channel(message.channel):
await self.claim_channel(message)
- else:
+
+ elif channel_utils.is_in_category(message.channel, constants.Categories.help_in_use):
+ await self.notify_session_participants(message)
await _message.update_message_caches(message)
@commands.Cog.listener()
@@ -522,3 +539,91 @@ class HelpChannels(commands.Cog):
)
self.dynamic_message = new_dynamic_message["id"]
await _caches.dynamic_message.set("message_id", self.dynamic_message)
+
+ @staticmethod
+ def _serialise_session_participants(participants: set[int]) -> str:
+ """Convert a set to a comma separated string."""
+ return ','.join(str(p) for p in participants)
+
+ @staticmethod
+ def _deserialise_session_participants(s: str) -> set[int]:
+ """Convert a comma separated string into a set."""
+ return set(int(user_id) for user_id in s.split(",") if user_id != "")
+
+ @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id"))
+ @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id"))
+ async def notify_session_participants(self, message: discord.Message) -> None:
+ """
+ Check if the message author meets the requirements to be notified.
+
+ If they meet the requirements they are notified.
+ """
+ if await _caches.claimants.get(message.channel.id) == message.author.id:
+ return # Ignore messages sent by claimants
+
+ if not await _caches.help_dm.get(message.author.id):
+ return # Ignore message if user is opted out of help dms
+
+ if (await self.bot.get_context(message)).command == self.close_command:
+ return # Ignore messages that are closing the channel
+
+ session_participants = self._deserialise_session_participants(
+ await _caches.session_participants.get(message.channel.id) or ""
+ )
+
+ if message.author.id not in session_participants:
+ session_participants.add(message.author.id)
+
+ embed = discord.Embed(
+ title="Currently Helping",
+ description=f"You're currently helping in {message.channel.mention}",
+ color=constants.Colours.soft_green,
+ timestamp=message.created_at
+ )
+ embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})")
+
+ try:
+ await message.author.send(embed=embed)
+ except discord.Forbidden:
+ log.trace(
+ f"Failed to send helpdm message to {message.author.id}. DMs Closed/Blocked. "
+ "Removing user from helpdm."
+ )
+ bot_commands_channel = self.bot.get_channel(Channels.bot_commands)
+ await _caches.help_dm.delete(message.author.id)
+ await bot_commands_channel.send(
+ f"{message.author.mention} {constants.Emojis.cross_mark} "
+ "To receive updates on help channels you're active in, enable your DMs.",
+ delete_after=RedirectOutput.delete_delay
+ )
+ return
+
+ await _caches.session_participants.set(
+ message.channel.id,
+ self._serialise_session_participants(session_participants)
+ )
+
+ @commands.command(name="helpdm")
+ async def helpdm_command(
+ self,
+ ctx: commands.Context,
+ state_bool: bool
+ ) -> None:
+ """
+ Allows user to toggle "Helping" dms.
+
+ If this is set to on the user will receive a dm for the channel they are participating in.
+
+ If this is set to off the user will not receive a dm for channel that they are participating in.
+ """
+ state_str = "ON" if state_bool else "OFF"
+
+ if state_bool == await _caches.help_dm.get(ctx.author.id, False):
+ await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already {state_str}")
+ return
+
+ if state_bool:
+ await _caches.help_dm.set(ctx.author.id, True)
+ else:
+ await _caches.help_dm.delete(ctx.author.id)
+ await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs {state_str}!")
diff --git a/bot/exts/help_channels/_cooldown.py b/bot/exts/help_channels/_cooldown.py
deleted file mode 100644
index c5c39297f..000000000
--- a/bot/exts/help_channels/_cooldown.py
+++ /dev/null
@@ -1,95 +0,0 @@
-import logging
-from typing import Callable, Coroutine
-
-import discord
-
-import bot
-from bot import constants
-from bot.exts.help_channels import _caches, _channel
-from bot.utils.scheduling import Scheduler
-
-log = logging.getLogger(__name__)
-CoroutineFunc = Callable[..., Coroutine]
-
-
-async def add_cooldown_role(member: discord.Member) -> None:
- """Add the help cooldown role to `member`."""
- log.trace(f"Adding cooldown role for {member} ({member.id}).")
- await _change_cooldown_role(member, member.add_roles)
-
-
-async def check_cooldowns(scheduler: Scheduler) -> None:
- """Remove expired cooldowns and re-schedule active ones."""
- log.trace("Checking all cooldowns to remove or re-schedule them.")
- guild = bot.instance.get_guild(constants.Guild.id)
- cooldown = constants.HelpChannels.claim_minutes * 60
-
- for channel_id, member_id in await _caches.claimants.items():
- member = guild.get_member(member_id)
- if not member:
- continue # Member probably left the guild.
-
- in_use_time = await _channel.get_in_use_time(channel_id)
-
- if not in_use_time or in_use_time.seconds > cooldown:
- # Remove the role if no claim time could be retrieved or if the cooldown expired.
- # Since the channel is in the claimants cache, it is definitely strange for a time
- # to not exist. However, it isn't a reason to keep the user stuck with a cooldown.
- await remove_cooldown_role(member)
- else:
- # The member is still on a cooldown; re-schedule it for the remaining time.
- delay = cooldown - in_use_time.seconds
- scheduler.schedule_later(delay, member.id, remove_cooldown_role(member))
-
-
-async def remove_cooldown_role(member: discord.Member) -> None:
- """Remove the help cooldown role from `member`."""
- log.trace(f"Removing cooldown role for {member} ({member.id}).")
- await _change_cooldown_role(member, member.remove_roles)
-
-
-async def revoke_send_permissions(member: discord.Member, scheduler: Scheduler) -> None:
- """
- Disallow `member` to send messages in the Available category for a certain time.
-
- The time until permissions are reinstated can be configured with
- `HelpChannels.claim_minutes`.
- """
- log.trace(
- f"Revoking {member}'s ({member.id}) send message permissions in the Available category."
- )
-
- await add_cooldown_role(member)
-
- # Cancel the existing task, if any.
- # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner).
- if member.id in scheduler:
- scheduler.cancel(member.id)
-
- delay = constants.HelpChannels.claim_minutes * 60
- scheduler.schedule_later(delay, member.id, remove_cooldown_role(member))
-
-
-async def _change_cooldown_role(member: discord.Member, coro_func: CoroutineFunc) -> None:
- """
- Change `member`'s cooldown role via awaiting `coro_func` and handle errors.
-
- `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
- """
- guild = bot.instance.get_guild(constants.Guild.id)
- role = guild.get_role(constants.Roles.help_cooldown)
- if role is None:
- log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!")
- return
-
- try:
- await coro_func(role)
- except discord.NotFound:
- log.debug(f"Failed to change role for {member} ({member.id}): member not found")
- except discord.Forbidden:
- log.debug(
- f"Forbidden to change role for {member} ({member.id}); "
- f"possibly due to role hierarchy"
- )
- except discord.HTTPException as e:
- log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")
diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py
index afd698ffe..befacd263 100644
--- a/bot/exts/help_channels/_message.py
+++ b/bot/exts/help_channels/_message.py
@@ -9,22 +9,20 @@ from arrow import Arrow
import bot
from bot import constants
from bot.exts.help_channels import _caches
-from bot.utils.channel import is_in_category
log = logging.getLogger(__name__)
ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"
AVAILABLE_MSG = f"""
-**Send your question here to claim the channel**
-This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue.
+Send your question here to claim the channel.
-**Keep in mind:**
-• It's always ok to just ask your question. You don't need permission.
-• Explain what you expect to happen and what actually happens.
-• Include a code sample and error message, if you got any.
+**Remember to:**
+• **Ask** your Python question, not if you can ask or if there's an expert who can help.
+• **Show** a code sample as text (rather than a screenshot) and the error message, if you got one.
+• **Explain** what you expect to happen and what actually happens.
-For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**.
+For more tips, check out our guide on [asking good questions]({ASKING_GUIDE_URL}).
"""
AVAILABLE_TITLE = "Available help channel"
@@ -47,23 +45,21 @@ async def update_message_caches(message: discord.Message) -> None:
"""Checks the source of new content in a help channel and updates the appropriate cache."""
channel = message.channel
- # Confirm the channel is an in use help channel
- if is_in_category(channel, constants.Categories.help_in_use):
- log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.")
+ log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.")
- claimant_id = await _caches.claimants.get(channel.id)
- if not claimant_id:
- # The mapping for this channel doesn't exist, we can't do anything.
- return
+ claimant_id = await _caches.claimants.get(channel.id)
+ if not claimant_id:
+ # The mapping for this channel doesn't exist, we can't do anything.
+ return
- # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time.
- timestamp = Arrow.fromdatetime(message.created_at).timestamp()
+ # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time.
+ timestamp = Arrow.fromdatetime(message.created_at).timestamp()
- # Overwrite the appropriate last message cache depending on the author of the message
- if message.author.id == claimant_id:
- await _caches.claimant_last_message_times.set(channel.id, timestamp)
- else:
- await _caches.non_claimant_last_message_times.set(channel.id, timestamp)
+ # Overwrite the appropriate last message cache depending on the author of the message
+ if message.author.id == claimant_id:
+ await _caches.claimant_last_message_times.set(channel.id, timestamp)
+ else:
+ await _caches.non_claimant_last_message_times.set(channel.id, timestamp)
async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]:
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index 2a8016fb8..c54a3ee1c 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -27,11 +27,12 @@ log = logging.getLogger(__name__)
# symbols with a group contained here will get the group prefixed on duplicates
FORCE_PREFIX_GROUPS = (
- "2to3fixer",
- "token",
+ "term",
"label",
+ "token",
+ "doc",
"pdbcommand",
- "term",
+ "2to3fixer",
)
NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
# Delay to wait before trying to reach a rescheduled inventory again, in minutes
@@ -181,22 +182,26 @@ class DocCog(commands.Cog):
else:
return new_name
- # Certain groups are added as prefixes to disambiguate the symbols.
- if group_name in FORCE_PREFIX_GROUPS:
- return rename(group_name)
-
- # The existing symbol with which the current symbol conflicts should have a group prefix.
- # It currently doesn't have the group prefix because it's only added once there's a conflict.
- elif item.group in FORCE_PREFIX_GROUPS:
- return rename(item.group, rename_extant=True)
+ # When there's a conflict, and the package names of the items differ, use the package name as a prefix.
+ if package_name != item.package:
+ if package_name in PRIORITY_PACKAGES:
+ return rename(item.package, rename_extant=True)
+ else:
+ return rename(package_name)
- elif package_name in PRIORITY_PACKAGES:
- return rename(item.package, rename_extant=True)
+ # If the symbol's group is a non-priority group from FORCE_PREFIX_GROUPS,
+ # add it as a prefix to disambiguate the symbols.
+ elif group_name in FORCE_PREFIX_GROUPS:
+ if item.group in FORCE_PREFIX_GROUPS:
+ needs_moving = FORCE_PREFIX_GROUPS.index(group_name) < FORCE_PREFIX_GROUPS.index(item.group)
+ else:
+ needs_moving = False
+ return rename(item.group if needs_moving else group_name, rename_extant=needs_moving)
- # If we can't specially handle the symbol through its group or package,
- # fall back to prepending its package name to the front.
+ # If the above conditions didn't pass, either the existing symbol has its group in FORCE_PREFIX_GROUPS,
+ # or deciding which item to rename would be arbitrary, so we rename the existing symbol.
else:
- return rename(package_name)
+ return rename(item.group, rename_extant=True)
async def refresh_inventories(self) -> None:
"""Refresh internal documentation inventories."""
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 834fee1b4..1b1243118 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -241,8 +241,6 @@ class Information(Cog):
if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)):
badges.append(emoji)
- activity = await self.user_messages(user)
-
if on_server:
joined = time_since(user.joined_at, max_units=3)
roles = ", ".join(role.mention for role in user.roles[1:])
@@ -272,8 +270,7 @@ class Information(Cog):
# Show more verbose output in moderation channels for infractions and nominations
if is_mod_channel(ctx.channel):
- fields.append(activity)
-
+ fields.append(await self.user_messages(user))
fields.append(await self.expanded_user_infraction_counts(user))
fields.append(await self.user_nomination_counts(user))
else:
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 988fb7220..8286d3635 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -47,12 +47,33 @@ class InfractionScheduler:
log.trace(f"Rescheduling infractions for {self.__class__.__name__}.")
infractions = await self.bot.api_client.get(
- 'bot/infractions',
- params={'active': 'true'}
+ "bot/infractions",
+ params={
+ "active": "true",
+ "ordering": "expires_at",
+ "permanent": "false",
+ "types": ",".join(supported_infractions),
+ },
)
- for infraction in infractions:
- if infraction["expires_at"] is not None and infraction["type"] in supported_infractions:
- self.schedule_expiration(infraction)
+
+ to_schedule = [i for i in infractions if i["id"] not in self.scheduler]
+
+ for infraction in to_schedule:
+ log.trace("Scheduling %r", infraction)
+ self.schedule_expiration(infraction)
+
+ # Call ourselves again when the last infraction would expire. This will be the "oldest" infraction we've seen
+ # from the database so far, and new ones are scheduled as part of application.
+ # We make sure to fire this
+ if to_schedule:
+ next_reschedule_point = max(
+ dateutil.parser.isoparse(infr["expires_at"]).replace(tzinfo=None) for infr in to_schedule
+ )
+ log.trace("Will reschedule remaining infractions at %s", next_reschedule_point)
+
+ self.scheduler.schedule_at(next_reschedule_point, -1, self.reschedule_infractions(supported_infractions))
+
+ log.trace("Done rescheduling")
async def reapply_infraction(
self,
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index a98b4828b..e4eb7f79c 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -164,7 +164,7 @@ async def notify_infraction(
text = INFRACTION_DESCRIPTION_TEMPLATE.format(
type=infr_type.title(),
- expires=expires_at or "N/A",
+ expires=f"{expires_at} UTC" if expires_at else "N/A",
reason=reason or "No reason provided."
)
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
index 0cbce6a51..8494a1e2e 100644
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -8,6 +8,7 @@ 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
@@ -40,6 +41,12 @@ VOICE_PING = (
"If you don't yet qualify, you'll be told why!"
)
+VOICE_PING_DM = (
+ "Wondering why you can't talk in the voice channels? "
+ "Use the `!voiceverify` command in {channel_mention} to verify. "
+ "If you don't yet qualify, you'll be told why!"
+)
+
class VoiceGate(Cog):
"""Voice channels verification management."""
@@ -75,37 +82,43 @@ class VoiceGate(Cog):
log.trace(f"Voice gate reminder message for user {member_id} was already removed")
@redis_cache.atomic_transaction
- async def _ping_newcomer(self, member: discord.Member) -> bool:
+ async def _ping_newcomer(self, member: discord.Member) -> tuple:
"""
See if `member` should be sent a voice verification notification, and send it if so.
- Returns False if the notification was not sent. This happens when:
+ Returns (False, None) if the notification was not sent. This happens when:
* The `member` has already received the notification
* The `member` is already voice-verified
- Otherwise, the notification message ID is stored in `redis_cache` and True is returned.
+ Otherwise, the notification message ID is stored in `redis_cache` and return (True, channel).
+ channel is either [discord.TextChannel, discord.DMChannel].
"""
if await self.redis_cache.contains(member.id):
log.trace("User already in cache. Ignore.")
- return False
+ return False, None
log.trace("User not in cache and is in a voice channel.")
verified = any(Roles.voice_verified == role.id for role in member.roles)
if verified:
log.trace("User is verified, add to the cache and ignore.")
await self.redis_cache.set(member.id, NO_MSG)
- return False
+ return False, None
log.trace("User is unverified. Send ping.")
+
await self.bot.wait_until_guild_available()
voice_verification_channel = self.bot.get_channel(Channels.voice_gate)
- message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}")
- await self.redis_cache.set(member.id, message.id)
+ try:
+ message = await member.send(VOICE_PING_DM.format(channel_mention=voice_verification_channel.mention))
+ except discord.Forbidden:
+ log.trace("DM failed for Voice ping message. Sending in channel.")
+ message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}")
- return True
+ await self.redis_cache.set(member.id, message.id)
+ return True, message.channel
- @command(aliases=('voiceverify',))
+ @command(aliases=("voiceverify", "voice-verify",))
@has_no_roles(Roles.voice_verified)
@in_whitelist(channels=(Channels.voice_gate,), redirect=None)
async def voice_verify(self, ctx: Context, *_) -> None:
@@ -144,8 +157,12 @@ class VoiceGate(Cog):
color=Colour.red()
)
log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.")
+ try:
+ await ctx.author.send(embed=embed)
+ except discord.Forbidden:
+ log.info("Could not send user DM. Sending in voice-verify channel and scheduling delete.")
+ await ctx.send(embed=embed)
- await ctx.author.send(embed=embed)
return
checks = {
@@ -237,13 +254,17 @@ class VoiceGate(Cog):
log.trace("User not in a voice channel. Ignore.")
return
+ if isinstance(after.channel, discord.StageChannel):
+ log.trace("User joined a stage channel. Ignore.")
+ return
+
# To avoid race conditions, checking if the user should receive a notification
# and sending it if appropriate is delegated to an atomic helper
- notification_sent = await self._ping_newcomer(member)
+ notification_sent, message_channel = await self._ping_newcomer(member)
- # Schedule the notification to be deleted after the configured delay, which is
+ # Schedule the channel ping notification to be deleted after the configured delay, which is
# again delegated to an atomic helper
- if notification_sent:
+ if notification_sent and isinstance(message_channel, discord.TextChannel):
await asyncio.sleep(GateConf.voice_ping_delete_delay)
await self._delete_ping(member.id)
diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py
index 3b44056d3..c6ee844ef 100644
--- a/bot/exts/moderation/watchchannels/bigbrother.py
+++ b/bot/exts/moderation/watchchannels/bigbrother.py
@@ -94,6 +94,11 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await ctx.send(f":x: {user} is already being watched.")
return
+ # FetchedUser instances don't have a roles attribute
+ if hasattr(user, "roles") and any(role.id in MODERATION_ROLES for role in user.roles):
+ await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I must be kind to my masters.")
+ return
+
response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True)
if response is not None:
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index d53c3b074..0cb786e4b 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -75,7 +75,7 @@ class Reviewer:
async def post_review(self, user_id: int, update_database: bool) -> None:
"""Format the review of a user and post it to the nomination voting channel."""
- review, seen_emoji = await self.make_review(user_id)
+ review, reviewed_emoji = await self.make_review(user_id)
if not review:
return
@@ -88,8 +88,8 @@ class Reviewer:
await pin_no_system_message(messages[0])
last_message = messages[-1]
- if seen_emoji:
- for reaction in (seen_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"):
+ if reviewed_emoji:
+ for reaction in (reviewed_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"):
await last_message.add_reaction(reaction)
if update_database:
@@ -97,7 +97,7 @@ class Reviewer:
await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{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 seen 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
@@ -120,21 +120,22 @@ class Reviewer:
opening = f"<@&{Roles.mod_team}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!"
current_nominations = "\n\n".join(
- f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" for entry in nomination['entries']
+ f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}"
+ for entry in nomination['entries'][::-1]
)
current_nominations = f"**Nominated by:**\n{current_nominations}"
review_body = await self._construct_review_body(member)
- seen_emoji = self._random_ducky(guild)
+ reviewed_emoji = self._random_ducky(guild)
vote_request = (
"*Refer to their nomination and infraction histories for further details*.\n"
- f"*Please react {seen_emoji} if you've seen this post."
- " Then react :+1: for approval, or :-1: for disapproval*."
+ f"*Please react {reviewed_emoji} once you have reviewed this user,"
+ " and react :+1: for approval, or :-1: for disapproval*."
)
review = "\n\n".join((opening, current_nominations, review_body, vote_request))
- return review, seen_emoji
+ return review, reviewed_emoji
async def archive_vote(self, message: PartialMessage, passed: bool) -> None:
"""Archive this vote to #nomination-archive."""
@@ -162,7 +163,7 @@ class Reviewer:
user_id = int(MENTION_RE.search(content).group(1))
# Get reaction counts
- seen = await count_unique_users_reaction(
+ reviewed = await count_unique_users_reaction(
messages[0],
lambda r: "ducky" in str(r) or str(r) == "\N{EYES}",
count_bots=False
@@ -187,7 +188,7 @@ class Reviewer:
embed_content = (
f"{result} on {timestamp}\n"
- f"With {seen} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n\n"
+ f"With {reviewed} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n\n"
f"{stripped_content}"
)
@@ -356,7 +357,7 @@ class Reviewer:
@staticmethod
def _random_ducky(guild: Guild) -> Union[Emoji, str]:
- """Picks a random ducky emoji to be used to mark the vote as seen. If no duckies found returns :eyes:."""
+ """Picks a random ducky emoji. If no duckies found returns :eyes:."""
duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")]
if not duckies:
return ":eyes:"
diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py
index a4c828f95..d84709616 100644
--- a/bot/exts/utils/bot.py
+++ b/bot/exts/utils/bot.py
@@ -44,6 +44,8 @@ class BotCog(Cog, name="Bot"):
"""Repeat the given message in either a specified channel or the current channel."""
if channel is None:
await ctx.send(text)
+ elif not channel.permissions_for(ctx.author).send_messages:
+ await ctx.send("You don't have permission to speak in that channel.")
else:
await channel.send(text)
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index 4c39a7c2a..3b8564aee 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -40,6 +40,7 @@ If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
"""
+LEADS_AND_COMMUNITY = (Roles.project_leads, Roles.domain_leads, Roles.partners, Roles.python_community)
class Utils(Cog):
@@ -185,7 +186,7 @@ class Utils(Cog):
)
@command(aliases=("poll",))
- @has_any_role(*MODERATION_ROLES, Roles.project_leads, Roles.domain_leads)
+ @has_any_role(*MODERATION_ROLES, *LEADS_AND_COMMUNITY)
async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None:
"""
Build a quick voting poll with matching reactions with the provided options.
diff --git a/bot/pagination.py b/bot/pagination.py
index c5c84afd9..1c5b94b07 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -51,22 +51,25 @@ class LinePaginator(Paginator):
suffix: str = '```',
max_size: int = 2000,
scale_to_size: int = 2000,
- max_lines: t.Optional[int] = None
+ max_lines: t.Optional[int] = None,
+ linesep: str = "\n"
) -> None:
"""
This function overrides the Paginator.__init__ from inside discord.ext.commands.
It overrides in order to allow us to configure the maximum number of lines per page.
"""
- self.prefix = prefix
- self.suffix = suffix
-
# Embeds that exceed 2048 characters will result in an HTTPException
# (Discord API limit), so we've set a limit of 2000
if max_size > 2000:
raise ValueError(f"max_size must be <= 2,000 characters. ({max_size} > 2000)")
- self.max_size = max_size - len(suffix)
+ super().__init__(
+ prefix,
+ suffix,
+ max_size - len(suffix),
+ linesep
+ )
if scale_to_size < max_size:
raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})")
diff --git a/bot/resources/tags/async-await.md b/bot/resources/tags/async-await.md
new file mode 100644
index 000000000..ff71ace07
--- /dev/null
+++ b/bot/resources/tags/async-await.md
@@ -0,0 +1,28 @@
+**Concurrency in Python**
+
+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.
+
+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:
+```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`
+
+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:
+```py
+from asyncio import get_event_loop
+
+async def main():
+ await something_awaitable()
+
+loop = get_event_loop()
+loop.run_until_complete(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()`.
+
+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/dunder-methods.md b/bot/resources/tags/dunder-methods.md
new file mode 100644
index 000000000..be2b97b7b
--- /dev/null
+++ b/bot/resources/tags/dunder-methods.md
@@ -0,0 +1,28 @@
+**Dunder methods**
+
+Double-underscore methods, or "dunder" methods, are special methods defined in a class that are invoked implicitly. Like the name suggests, they are prefixed and suffixed with dunders. You've probably already seen some, such as the `__init__` dunder method, also known as the "constructor" of a class, which is implicitly invoked when you instantiate an instance of a class.
+
+When you create a new class, there will be default dunder methods inherited from the `object` class. However, we can override them by redefining these methods within the new class. For example, the default `__init__` method from `object` doesn't take any arguments, so we almost always override that to fit our needs.
+
+Other common dunder methods to override are `__str__` and `__repr__`. `__repr__` is the developer-friendly string representation of an object - usually the syntax to recreate it - and is implicitly called on arguments passed into the `repr` function. `__str__` is the user-friendly string representation of an object, and is called by the `str` function. Note here that, if not overriden, the default `__str__` invokes `__repr__` as a fallback.
+
+```py
+class Foo:
+ def __init__(self, value): # constructor
+ self.value = value
+ def __str__(self):
+ return f"This is a Foo object, with a value of {self.value}!" # string representation
+ def __repr__(self):
+ return f"Foo({self.value!r})" # way to recreate this object
+
+
+bar = Foo(5)
+
+# print also implicitly calls __str__
+print(bar) # Output: This is a Foo object, with a value of 5!
+
+# dev-friendly representation
+print(repr(bar)) # Output: Foo(5)
+```
+
+Another example: did you know that when you use the `<left operand> + <right operand>` syntax, you're implicitly calling `<left operand>.__add__(<right operand>)`? The same applies to other operators, and you can look at the [`operator` built-in module documentation](https://docs.python.org/3/library/operator.html) for more information!
diff --git a/bot/resources/tags/floats.md b/bot/resources/tags/floats.md
index 7129b91bb..03fcd7268 100644
--- a/bot/resources/tags/floats.md
+++ b/bot/resources/tags/floats.md
@@ -5,7 +5,7 @@ You may have noticed that when doing arithmetic with floats in Python you someti
0.30000000000000004
```
**Why this happens**
-Internally your computer stores floats as as binary fractions. Many decimal values cannot be stored as exact binary fractions, which means an approximation has to be used.
+Internally your computer stores floats as binary fractions. Many decimal values cannot be stored as exact binary fractions, which means an approximation has to be used.
**How you can avoid this**
You can use [math.isclose](https://docs.python.org/3/library/math.html#math.isclose) to check if two floats are close, or to get an exact decimal representation, you can use the [decimal](https://docs.python.org/3/library/decimal.html) or [fractions](https://docs.python.org/3/library/fractions.html) module. Here are some examples:
diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md
index 7545419ee..412468174 100644
--- a/bot/resources/tags/modmail.md
+++ b/bot/resources/tags/modmail.md
@@ -6,4 +6,4 @@ It supports attachments, codeblocks, and reactions. As communication happens ove
**To use it, simply send a direct message to the bot.**
-Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&267629731250176001> or <@&267628507062992896> role instead.
+Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&831776746206265384> or <@&267628507062992896> role instead.
diff --git a/bot/resources/tags/star-imports.md b/bot/resources/tags/star-imports.md
index 2be6aab6e..3b1b6a858 100644
--- a/bot/resources/tags/star-imports.md
+++ b/bot/resources/tags/star-imports.md
@@ -16,33 +16,24 @@ Example:
>>> from math import *
>>> sin(pi / 2) # uses sin from math rather than your custom sin
```
-
• Potential namespace collision. Names defined from a previous import might get shadowed by a wildcard import.
-
• Causes ambiguity. From the example, it is unclear which `sin` function is actually being used. From the Zen of Python **[3]**: `Explicit is better than implicit.`
-
• Makes import order significant, which they shouldn't. Certain IDE's `sort import` functionality may end up breaking code due to namespace collision.
**How should you import?**
• Import the module under the module's namespace (Only import the name of the module, and names defined in the module can be used by prefixing the module's name)
-
```python
>>> import math
>>> math.sin(math.pi / 2)
```
-
• Explicitly import certain names from the module
-
```python
>>> from math import sin, pi
>>> sin(pi / 2)
```
-
Conclusion: Namespaces are one honking great idea -- let's do more of those! *[3]*
**[1]** If the module defines the variable `__all__`, the names defined in `__all__` will get imported by the wildcard import, otherwise all the names in the module get imported (except for names with a leading underscore)
-
**[2]** [Namespaces and scopes](https://www.programiz.com/python-programming/namespace)
-
**[3]** [Zen of Python](https://www.python.org/dev/peps/pep-0020/)
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index b6f6c1f66..d4a921161 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -54,7 +54,7 @@ def reaction_check(
log.trace(f"Removing reaction {reaction} by {user} on {reaction.message.id}: disallowed user.")
scheduling.create_task(
reaction.message.remove_reaction(reaction.emoji, user),
- HTTPException, # Suppress the HTTPException if adding the reaction fails
+ suppressed_exceptions=(HTTPException,),
name=f"remove_reaction-{reaction}-{reaction.message.id}-{user}"
)
return False
diff --git a/bot/utils/regex.py b/bot/utils/regex.py
index 0d2068f90..a8efe1446 100644
--- a/bot/utils/regex.py
+++ b/bot/utils/regex.py
@@ -5,6 +5,7 @@ INVITE_RE = re.compile(
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
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index 2dc485f24..bb83b5c0d 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -161,9 +161,22 @@ class Scheduler:
self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception)
-def create_task(coro: t.Awaitable, *suppressed_exceptions: t.Type[Exception], **kwargs) -> asyncio.Task:
- """Wrapper for `asyncio.create_task` which logs exceptions raised in the task."""
- task = asyncio.create_task(coro, **kwargs)
+def create_task(
+ coro: t.Awaitable,
+ *,
+ suppressed_exceptions: tuple[t.Type[Exception]] = (),
+ event_loop: t.Optional[asyncio.AbstractEventLoop] = None,
+ **kwargs,
+) -> asyncio.Task:
+ """
+ Wrapper for creating asyncio `Task`s which logs exceptions raised in the task.
+
+ If the loop kwarg is provided, the task is created from that event loop, otherwise the running loop is used.
+ """
+ if event_loop is not None:
+ task = event_loop.create_task(coro, **kwargs)
+ else:
+ task = asyncio.create_task(coro, **kwargs)
task.add_done_callback(partial(_log_task_exception, suppressed_exceptions=suppressed_exceptions))
return task
diff --git a/config-default.yml b/config-default.yml
index 8c30ecf69..7bc176135 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -177,6 +177,9 @@ guild:
user_log: 528976905546760203
voice_log: 640292421988646961
+ # Open Source Projects
+ black_formatter: &BLACK_FORMATTER 846434317021741086
+
# Off-topic
off_topic_0: 291284109232308226
off_topic_1: 463035241142026251
@@ -197,6 +200,7 @@ guild:
incidents: 714214212200562749
incidents_archive: 720668923636351037
mod_alerts: 473092532147060736
+ mods: &MODS 305126844661760000
nominations: 822920136150745168
nomination_voting: 822853512709931008
organisation: &ORGANISATION 551789653284356126
@@ -233,6 +237,7 @@ guild:
moderation_channels:
- *ADMINS
- *ADMIN_SPAM
+ - *MODS
# Modlog cog ignores events which occur in these channels
modlog_blacklist:
@@ -246,6 +251,7 @@ guild:
reminder_whitelist:
- *BOT_CMD
- *DEV_CONTRIB
+ - *BLACK_FORMATTER
roles:
announcements: 463658397560995840
@@ -395,7 +401,7 @@ anti_spam:
chars:
interval: 5
- max: 3_000
+ max: 4_200
discord_emojis:
interval: 10
@@ -466,9 +472,6 @@ free:
help_channels:
enable: true
- # Minimum interval before allowing a certain user to claim a new help channel
- claim_minutes: 15
-
# Roles which are allowed to use the command which makes channels dormant
cmd_whitelist:
- *HELPERS_ROLE
@@ -514,7 +517,7 @@ redirect_output:
duck_pond:
- threshold: 5
+ threshold: 7
channel_blacklist:
- *ANNOUNCEMENTS
- *PYNEWS_CHANNEL
diff --git a/docker-compose.yml b/docker-compose.yml
index bdfedf5c2..0f0355dac 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,13 +12,13 @@ x-logging: &logging
max-size: "10m"
x-restart-policy: &restart_policy
- restart: always
+ restart: unless-stopped
services:
postgres:
<< : *logging
<< : *restart_policy
- image: postgres:12-alpine
+ image: postgres:13-alpine
environment:
POSTGRES_DB: pysite
POSTGRES_PASSWORD: pysite
diff --git a/poetry.lock b/poetry.lock
index ba8b7af4b..2041824e2 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -125,6 +125,14 @@ optional = false
python-versions = ">=3.5.3"
[[package]]
+name = "atomicwrites"
+version = "1.4.0"
+description = "Atomic file writes."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
name = "attrs"
version = "21.2.0"
description = "Classes Without Boilerplate"
@@ -155,7 +163,7 @@ lxml = ["lxml"]
[[package]]
name = "certifi"
-version = "2020.12.5"
+version = "2021.5.30"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
@@ -174,7 +182,7 @@ pycparser = "*"
[[package]]
name = "cfgv"
-version = "3.2.0"
+version = "3.3.0"
description = "Validate configuration and produce human readable error messages."
category = "dev"
optional = false
@@ -253,7 +261,7 @@ murmur = ["mmh3"]
[[package]]
name = "discord.py"
-version = "1.6.0"
+version = "1.7.3"
description = "A Python wrapper for the Discord API"
category = "main"
optional = false
@@ -268,7 +276,7 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"]
[[package]]
name = "distlib"
-version = "0.3.1"
+version = "0.3.2"
description = "Distribution utilities"
category = "dev"
optional = false
@@ -294,8 +302,19 @@ python-versions = "*"
dev = ["pytest", "coverage", "coveralls"]
[[package]]
+name = "execnet"
+version = "1.9.0"
+description = "execnet: rapid multi-Python deployment"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+testing = ["pre-commit"]
+
+[[package]]
name = "fakeredis"
-version = "1.5.0"
+version = "1.5.2"
description = "Fake implementation of redis API for testing purposes."
category = "main"
optional = false
@@ -307,12 +326,12 @@ six = ">=1.12"
sortedcontainers = "*"
[package.extras]
-aioredis = ["aioredis"]
+aioredis = ["aioredis (<2)"]
lua = ["lupa"]
[[package]]
name = "feedparser"
-version = "6.0.2"
+version = "6.0.8"
description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds"
category = "main"
optional = false
@@ -456,7 +475,7 @@ python-versions = ">=3.6"
[[package]]
name = "humanfriendly"
-version = "9.1"
+version = "9.2"
description = "Human friendly output for text interfaces using Python"
category = "main"
optional = false
@@ -467,7 +486,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""}
[[package]]
name = "identify"
-version = "2.2.4"
+version = "2.2.10"
description = "File identification library for Python"
category = "dev"
optional = false
@@ -478,11 +497,19 @@ license = ["editdistance-s"]
[[package]]
name = "idna"
-version = "3.1"
+version = "3.2"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
-python-versions = ">=3.4"
+python-versions = ">=3.5"
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = "*"
[[package]]
name = "lxml"
@@ -520,7 +547,7 @@ python-versions = "*"
[[package]]
name = "more-itertools"
-version = "8.7.0"
+version = "8.8.0"
description = "More routines for operating on iterables, beyond itertools"
category = "main"
optional = false
@@ -559,6 +586,17 @@ optional = false
python-versions = ">=3.5"
[[package]]
+name = "packaging"
+version = "20.9"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+pyparsing = ">=2.0.2"
+
+[[package]]
name = "pamqp"
version = "2.3.0"
description = "RabbitMQ Focused AMQP low-level library"
@@ -581,8 +619,19 @@ python-versions = "*"
flake8-polyfill = ">=1.0.2,<2"
[[package]]
+name = "pluggy"
+version = "0.13.1"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+
+[[package]]
name = "pre-commit"
-version = "2.12.1"
+version = "2.13.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev"
optional = false
@@ -608,8 +657,16 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]
[[package]]
+name = "py"
+version = "1.10.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
name = "pycares"
-version = "3.2.3"
+version = "4.0.0"
description = "Python interface for c-ares"
category = "main"
optional = false
@@ -639,7 +696,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pydocstyle"
-version = "6.0.0"
+version = "6.1.1"
description = "Python docstring style checker"
category = "dev"
optional = false
@@ -648,6 +705,9 @@ python-versions = ">=3.6"
[package.dependencies]
snowballstemmer = "*"
+[package.extras]
+toml = ["toml"]
+
[[package]]
name = "pyflakes"
version = "2.3.1"
@@ -657,6 +717,14 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
+name = "pyparsing"
+version = "2.4.7"
+description = "Python parsing module"
+category = "dev"
+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."
@@ -665,6 +733,73 @@ optional = false
python-versions = "*"
[[package]]
+name = "pytest"
+version = "6.2.4"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
+attrs = ">=19.2.0"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<1.0.0a1"
+py = ">=1.8.2"
+toml = "*"
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+
+[[package]]
+name = "pytest-cov"
+version = "2.12.1"
+description = "Pytest plugin for measuring coverage."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+coverage = ">=5.2.1"
+pytest = ">=4.6"
+toml = "*"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
+
+[[package]]
+name = "pytest-forked"
+version = "1.3.0"
+description = "run tests in isolated forked subprocesses"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+py = "*"
+pytest = ">=3.10"
+
+[[package]]
+name = "pytest-xdist"
+version = "2.3.0"
+description = "pytest xdist plugin for distributed testing and loop-on-failing modes"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+execnet = ">=1.1"
+psutil = {version = ">=3.0", optional = true, markers = "extra == \"psutil\""}
+pytest = ">=6.0.0"
+pytest-forked = "*"
+
+[package.extras]
+psutil = ["psutil (>=3.0)"]
+testing = ["filelock"]
+
+[[package]]
name = "python-dateutil"
version = "2.8.1"
description = "Extensions to the standard Python datetime module"
@@ -794,7 +929,7 @@ python-versions = "*"
[[package]]
name = "sortedcontainers"
-version = "2.3.0"
+version = "2.4.0"
description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
category = "main"
optional = false
@@ -847,20 +982,20 @@ python-versions = "*"
[[package]]
name = "urllib3"
-version = "1.26.4"
+version = "1.26.6"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras]
+brotli = ["brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
-brotli = ["brotlipy (>=0.6.0)"]
[[package]]
name = "virtualenv"
-version = "20.4.6"
+version = "20.4.7"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
@@ -891,7 +1026,7 @@ multidict = ">=4.0"
[metadata]
lock-version = "1.1"
python-versions = "3.9.*"
-content-hash = "ece3b915901a62911ff7ff4a616b3972e815c0e1c7097c8994163af13cadde0e"
+content-hash = "feec7372374cc4025f407b64b2e5b45c2c9c8d49c4538b91dc372f2bad89a624"
[metadata.files]
aio-pika = [
@@ -969,6 +1104,10 @@ async-timeout = [
{file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"},
{file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"},
]
+atomicwrites = [
+ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
+ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
+]
attrs = [
{file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
{file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
@@ -979,8 +1118,8 @@ beautifulsoup4 = [
{file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"},
]
certifi = [
- {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
- {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
+ {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
+ {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
]
cffi = [
{file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"},
@@ -1022,8 +1161,8 @@ cffi = [
{file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"},
]
cfgv = [
- {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"},
- {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"},
+ {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"},
+ {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"},
]
chardet = [
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
@@ -1100,12 +1239,12 @@ deepdiff = [
{file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"},
]
"discord.py" = [
- {file = "discord.py-1.6.0-py3-none-any.whl", hash = "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12"},
- {file = "discord.py-1.6.0.tar.gz", hash = "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f"},
+ {file = "discord.py-1.7.3-py3-none-any.whl", hash = "sha256:c6f64db136de0e18e090f6752ea68bdd4ab0a61b82dfe7acecefa22d6477bb0c"},
+ {file = "discord.py-1.7.3.tar.gz", hash = "sha256:462cd0fe307aef8b29cbfa8dd613e548ae4b2cb581d46da9ac0d46fb6ea19408"},
]
distlib = [
- {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"},
- {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"},
+ {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"},
+ {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"},
]
docopt = [
{file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
@@ -1113,13 +1252,17 @@ docopt = [
emoji = [
{file = "emoji-0.6.0.tar.gz", hash = "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"},
]
+execnet = [
+ {file = "execnet-1.9.0-py2.py3-none-any.whl", hash = "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142"},
+ {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"},
+]
fakeredis = [
- {file = "fakeredis-1.5.0-py3-none-any.whl", hash = "sha256:e0416e4941cecd3089b0d901e60c8dc3c944f6384f5e29e2261c0d3c5fa99669"},
- {file = "fakeredis-1.5.0.tar.gz", hash = "sha256:1ac0cef767c37f51718874a33afb5413e69d132988cb6a80c6e6dbeddf8c7623"},
+ {file = "fakeredis-1.5.2-py3-none-any.whl", hash = "sha256:f1ffdb134538e6d7c909ddfb4fc5edeb4a73d0ea07245bc69b8135fbc4144b04"},
+ {file = "fakeredis-1.5.2.tar.gz", hash = "sha256:18fc1808d2ce72169d3f11acdb524a00ef96bd29970c6d34cfeb2edb3fc0c020"},
]
feedparser = [
- {file = "feedparser-6.0.2-py3-none-any.whl", hash = "sha256:f596c4b34fb3e2dc7e6ac3a8191603841e8d5d267210064e94d4238737452ddd"},
- {file = "feedparser-6.0.2.tar.gz", hash = "sha256:1b00a105425f492f3954fd346e5b524ca9cef3a4bbf95b8809470e9857aa1074"},
+ {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"},
@@ -1208,16 +1351,20 @@ hiredis = [
{file = "hiredis-2.0.0.tar.gz", hash = "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a"},
]
humanfriendly = [
- {file = "humanfriendly-9.1-py2.py3-none-any.whl", hash = "sha256:d5c731705114b9ad673754f3317d9fa4c23212f36b29bdc4272a892eafc9bc72"},
- {file = "humanfriendly-9.1.tar.gz", hash = "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d"},
+ {file = "humanfriendly-9.2-py2.py3-none-any.whl", hash = "sha256:332da98c24cc150efcc91b5508b19115209272bfdf4b0764a56795932f854271"},
+ {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"},
]
identify = [
- {file = "identify-2.2.4-py2.py3-none-any.whl", hash = "sha256:ad9f3fa0c2316618dc4d840f627d474ab6de106392a4f00221820200f490f5a8"},
- {file = "identify-2.2.4.tar.gz", hash = "sha256:9bcc312d4e2fa96c7abebcdfb1119563b511b5e3985ac52f60d9116277865b2e"},
+ {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"},
+ {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"},
]
idna = [
- {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"},
- {file = "idna-3.1.tar.gz", hash = "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"},
+ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
+ {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
+]
+iniconfig = [
+ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
lxml = [
{file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"},
@@ -1276,8 +1423,8 @@ mccabe = [
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
]
more-itertools = [
- {file = "more-itertools-8.7.0.tar.gz", hash = "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"},
- {file = "more_itertools-8.7.0-py3-none-any.whl", hash = "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced"},
+ {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"},
+ {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"},
]
mslex = [
{file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"},
@@ -1329,6 +1476,10 @@ nodeenv = [
ordered-set = [
{file = "ordered-set-4.0.2.tar.gz", hash = "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"},
]
+packaging = [
+ {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
+ {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
+]
pamqp = [
{file = "pamqp-2.3.0-py2.py3-none-any.whl", hash = "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02"},
{file = "pamqp-2.3.0.tar.gz", hash = "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"},
@@ -1337,9 +1488,13 @@ pep8-naming = [
{file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"},
{file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"},
]
+pluggy = [
+ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
+ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
+]
pre-commit = [
- {file = "pre_commit-2.12.1-py2.py3-none-any.whl", hash = "sha256:70c5ec1f30406250b706eda35e868b87e3e4ba099af8787e3e8b4b01e84f4712"},
- {file = "pre_commit-2.12.1.tar.gz", hash = "sha256:900d3c7e1bf4cf0374bb2893c24c23304952181405b4d88c9c40b72bda1bb8a9"},
+ {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"},
]
psutil = [
{file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"},
@@ -1371,40 +1526,44 @@ psutil = [
{file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"},
{file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"},
]
+py = [
+ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
+ {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
+]
pycares = [
- {file = "pycares-3.2.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ebff743643e54aa70dce0b7098094edefd371641cf79d9c944e9f4a25e9242b0"},
- {file = "pycares-3.2.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:55272411b46787936e8db475b9b6e9b81a8d8cdc253fa8779a45ef979f554fab"},
- {file = "pycares-3.2.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f33ed0e403f98e746f721aeacde917f1bdc7558cb714d713c264848bddff660f"},
- {file = "pycares-3.2.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:72807e0c80b705e21c3a39347c12edf43aa4f80373bb37777facf810169372ed"},
- {file = "pycares-3.2.3-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a51df0a8b3eaf225e0dae3a737fd6ce6f3cb2a3bc947e884582fdda9a159d55f"},
- {file = "pycares-3.2.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:663b5c7bd0f66436adac7257ee22ccfe185c3e7830b9bada3d19b79870e1d134"},
- {file = "pycares-3.2.3-cp36-cp36m-win32.whl", hash = "sha256:c2b1e19262ce91c3288b1905b0d41f7ad0fff4b258ce37b517aa2c8d22eb82f1"},
- {file = "pycares-3.2.3-cp36-cp36m-win_amd64.whl", hash = "sha256:e16399654a6c81cfaee2745857c119c20357b5d93de2f169f506b048b5e75d1d"},
- {file = "pycares-3.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88e5131570d7323b29866aa5ac245a9a5788d64677111daa1bde5817acdf012f"},
- {file = "pycares-3.2.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1552ffd823dc595fa8744c996926097a594f4f518d7c147657234b22cf17649d"},
- {file = "pycares-3.2.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f9e28b917373818817aca746238fcd621ec7e4ae9cbc8615f1a045e234eec298"},
- {file = "pycares-3.2.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:206d5a652990f10a1f1f3f62bc23d7fe46d99c2dc4b8b8a5101e5a472986cd02"},
- {file = "pycares-3.2.3-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b8c9670225cdeeeb2b85ea92a807484622ca59f8f578ec73e8ec292515f35a91"},
- {file = "pycares-3.2.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:6329160885fc318f80692d4d0a83a8854f9144e7a80c4f25245d0c26f11a4b84"},
- {file = "pycares-3.2.3-cp37-cp37m-win32.whl", hash = "sha256:cd0f7fb40e1169f00b26a12793136bf5c711f155e647cd045a0ce6c98a527b57"},
- {file = "pycares-3.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:a5d419215543d154587590d9d4485e985387ca10c7d3e1a2e5689dd6c0f20e5f"},
- {file = "pycares-3.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54f1c0642935515f27549f09486e72b6b2b1d51ad27a90ce17b760e9ce5e86d"},
- {file = "pycares-3.2.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6ce80eed538dd6106cd7e6136ceb3af10178d1254f07096a827c12e82e5e45c8"},
- {file = "pycares-3.2.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ed972a04067e91f552da84945d38b94c3984c898f699faa8bb066e9f3a114c32"},
- {file = "pycares-3.2.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:99a62b101cfb36ab6ebf19cb1ad60db2f9b080dc52db4ca985fe90924f60c758"},
- {file = "pycares-3.2.3-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:2246adcbc948dd31925c9bff5cc41c06fc640f7d982e6b41b6d09e4f201e5c11"},
- {file = "pycares-3.2.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7fd15d3f32be5548f38f95f4762ca73eef9fd623b101218a35d433ee0d4e3b58"},
- {file = "pycares-3.2.3-cp38-cp38-win32.whl", hash = "sha256:4bb0c708d8713741af7c4649d2f11e47c5f4e43131831243aeb18cff512c5469"},
- {file = "pycares-3.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:a53d921956d1e985e510ca0ffa84fbd7ecc6ac7d735d8355cba4395765efcd31"},
- {file = "pycares-3.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0312d25fa9d7c242f66115c4b3ae6ed8aedb457513ba33acef31fa265fc602b4"},
- {file = "pycares-3.2.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9960de8254525d9c3b485141809910c39d5eb1bb8119b1453702aacf72234934"},
- {file = "pycares-3.2.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:929f708a7bb4b2548cbbfc2094b2f90c4d8712056cdc0204788b570ab69c8838"},
- {file = "pycares-3.2.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4dd1237f01037cf5b90dd599c7fa79d9d8fb2ab2f401e19213d24228b2d17838"},
- {file = "pycares-3.2.3-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5eea61a74097976502ce377bb75c4fed381d4986bc7fb85e70b691165133d3da"},
- {file = "pycares-3.2.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1c72c0fda4b08924fe04680475350e09b8d210365d950a6dcdde8c449b8d5b98"},
- {file = "pycares-3.2.3-cp39-cp39-win32.whl", hash = "sha256:b1555d51ce29510ffd20f9e0339994dff8c5d1cb093c8e81d5d98f474e345aa7"},
- {file = "pycares-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:43c15138f620ed28e61e51b884490eb8387e5954668f919313753f88dd8134fd"},
- {file = "pycares-3.2.3.tar.gz", hash = "sha256:da1899fde778f9b8736712283eccbf7b654248779b349d139cd28eb30b0fa8cd"},
+ {file = "pycares-4.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:db5a533111a3cfd481e7e4fb2bf8bef69f4fa100339803e0504dd5aecafb96a5"},
+ {file = "pycares-4.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fdff88393c25016f417770d82678423fc7a56995abb2df3d2a1e55725db6977d"},
+ {file = "pycares-4.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0aa97f900a7ffb259be77d640006585e2a907b0cd4edeee0e85cf16605995d5a"},
+ {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:a34b0e3e693dceb60b8a1169668d606c75cb100ceba0a2df53c234a0eb067fbc"},
+ {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7661d6bbd51a337e7373cb356efa8be9b4655fda484e068f9455e939aec8d54e"},
+ {file = "pycares-4.0.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:57315b8eb8fdbc56b3ad4932bc4b17132bb7c7fd2bd590f7fb84b6b522098aa9"},
+ {file = "pycares-4.0.0-cp36-cp36m-win32.whl", hash = "sha256:dca9dc58845a9d083f302732a3130c68ded845ad5d463865d464e53c75a3dd45"},
+ {file = "pycares-4.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c95c964d5dd307e104b44b193095c67bb6b10c9eda1ffe7d44ab7a9e84c476d9"},
+ {file = "pycares-4.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26e67e4f81c80a5955dcf6193f3d9bee3c491fc0056299b383b84d792252fba4"},
+ {file = "pycares-4.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd3011ffd5e1ad55880f7256791dbab9c43ebeda260474a968f19cd0319e1aef"},
+ {file = "pycares-4.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1b959dd5921d207d759d421eece1b60416df33a7f862465739d5f2c363c2f523"},
+ {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6f258c1b74c048a9501a25f732f11b401564005e5e3c18f1ca6cad0c3dc0fb19"},
+ {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b17ef48729786e62b574c6431f675f4cb02b27691b49e7428a605a50cd59c072"},
+ {file = "pycares-4.0.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:82b3259cb590ddd107a6d2dc52da2a2e9a986bf242e893d58c786af2f8191047"},
+ {file = "pycares-4.0.0-cp37-cp37m-win32.whl", hash = "sha256:4876fc790ae32832ae270c4a010a1a77e12ddf8d8e6ad70ad0b0a9d506c985f7"},
+ {file = "pycares-4.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f60c04c5561b1ddf85ca4e626943cc09d7fb684e1adb22abb632095415a40fd7"},
+ {file = "pycares-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:615406013cdcd1b445e5d1a551d276c6200b3abe77e534f8a7f7e1551208d14f"},
+ {file = "pycares-4.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6580aef5d1b29a88c3d72fe73c691eacfd454f86e74d3fdd18f4bad8e8def98b"},
+ {file = "pycares-4.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8ebb3ba0485f66cae8eed7ce3e9ed6f2c0bfd5e7319d5d0fbbb511064f17e1d4"},
+ {file = "pycares-4.0.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c5362b7690ca481440f6b98395ac6df06aa50518ccb183c560464d1e5e2ab5d4"},
+ {file = "pycares-4.0.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:eb60be66accc9a9ea1018b591a1f5800cba83491d07e9acc8c56bc6e6607ab54"},
+ {file = "pycares-4.0.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:44896d6e191a6b5a914dbe3aa7c748481bf6ad19a9df33c1e76f8f2dc33fc8f0"},
+ {file = "pycares-4.0.0-cp38-cp38-win32.whl", hash = "sha256:09b28fc7bc2cc05f7f69bf1636ddf46086e0a1837b62961e2092fcb40477320d"},
+ {file = "pycares-4.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4a5081e232c1d181883dcac4675807f3a6cf33911c4173fbea00c0523687ed4"},
+ {file = "pycares-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:103353577a6266a53e71bfee4cf83825f1401fefa60f0fb8bdec35f13be6a5f2"},
+ {file = "pycares-4.0.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ad6caf580ee69806fc6534be93ddbb6e99bf94296d79ab351c37b2992b17abfd"},
+ {file = "pycares-4.0.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3d5e50c95849f6905d2a9dbf02ed03f82580173e3c5604a39e2ad054185631f1"},
+ {file = "pycares-4.0.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:53bc4f181b19576499b02cea4b45391e8dcbe30abd4cd01492f66bfc15615a13"},
+ {file = "pycares-4.0.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:d52f9c725d2a826d5ffa37681eb07ffb996bfe21788590ef257664a3898fc0b5"},
+ {file = "pycares-4.0.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3c7fb8d34ee11971c39acfaf98d0fac66725385ccef3bfe1b174c92b210e1aa4"},
+ {file = "pycares-4.0.0-cp39-cp39-win32.whl", hash = "sha256:e9773e07684a55f54657df05237267611a77b294ec3bacb5f851c4ffca38a465"},
+ {file = "pycares-4.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:38e54037f36c149146ff15f17a4a963fbdd0f9871d4a21cd94ff9f368140f57e"},
+ {file = "pycares-4.0.0.tar.gz", hash = "sha256:d0154fc5753b088758fbec9bc137e1b24bb84fc0c6a09725c8bac25a342311cd"},
]
pycodestyle = [
{file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
@@ -1415,18 +1574,38 @@ pycparser = [
{file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
]
pydocstyle = [
- {file = "pydocstyle-6.0.0-py3-none-any.whl", hash = "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d"},
- {file = "pydocstyle-6.0.0.tar.gz", hash = "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f"},
+ {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"},
+ {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"},
]
pyflakes = [
{file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
{file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
]
+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"},
]
+pytest = [
+ {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"},
+ {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"},
+]
+pytest-cov = [
+ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"},
+ {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"},
+]
+pytest-forked = [
+ {file = "pytest-forked-1.3.0.tar.gz", hash = "sha256:6aa9ac7e00ad1a539c41bec6d21011332de671e938c7637378ec9710204e37ca"},
+ {file = "pytest_forked-1.3.0-py2.py3-none-any.whl", hash = "sha256:dc4147784048e70ef5d437951728825a131b81714b398d5d52f17c7c144d8815"},
+]
+pytest-xdist = [
+ {file = "pytest-xdist-2.3.0.tar.gz", hash = "sha256:e8ecde2f85d88fbcadb7d28cb33da0fa29bca5cf7d5967fa89fc0e97e5299ea5"},
+ {file = "pytest_xdist-2.3.0-py3-none-any.whl", hash = "sha256:ed3d7da961070fce2a01818b51f6888327fb88df4379edeb6b9d990e789d9c8d"},
+]
python-dateutil = [
{file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
{file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
@@ -1529,8 +1708,8 @@ snowballstemmer = [
{file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"},
]
sortedcontainers = [
- {file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"},
- {file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"},
+ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
+ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
]
soupsieve = [
{file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"},
@@ -1554,12 +1733,12 @@ typing-extensions = [
{file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},
]
urllib3 = [
- {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"},
- {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"},
+ {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"},
+ {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"},
]
virtualenv = [
- {file = "virtualenv-20.4.6-py2.py3-none-any.whl", hash = "sha256:307a555cf21e1550885c82120eccaf5acedf42978fd362d32ba8410f9593f543"},
- {file = "virtualenv-20.4.6.tar.gz", hash = "sha256:72cf267afc04bf9c86ec932329b7e94db6a0331ae9847576daaa7ca3c86b29a4"},
+ {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"},
+ {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"},
]
yarl = [
{file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"},
diff --git a/pyproject.toml b/pyproject.toml
index 320bf88cc..c76bb47d6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,7 +18,7 @@ beautifulsoup4 = "~=4.9"
colorama = { version = "~=0.4.3", markers = "sys_platform == 'win32'" }
coloredlogs = "~=14.0"
deepdiff = "~=4.0"
-"discord.py" = "~=1.6.0"
+"discord.py" = "~=1.7.3"
emoji = "~=0.6"
feedparser = "~=6.0.2"
fuzzywuzzy = "~=0.17"
@@ -47,6 +47,9 @@ pep8-naming = "~=0.9"
pre-commit = "~=2.1"
taskipy = "~=1.7.0"
python-dotenv = "~=0.17.1"
+pytest = "~=6.2.4"
+pytest-cov = "~=2.12.1"
+pytest-xdist = { version = "~=2.3.0", extras = ["psutil"] }
[build-system]
requires = ["poetry-core>=1.0.0"]
@@ -58,6 +61,12 @@ lint = "pre-commit run --all-files"
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 = "coverage run -m unittest"
+test-nocov = "pytest -n auto"
+test = "pytest -n auto --cov-report= --cov"
html = "coverage html"
report = "coverage report"
+
+[tool.coverage.run]
+branch = true
+source_pkgs = ["bot"]
+source = ["tests"]
diff --git a/tests/README.md b/tests/README.md
index 1a17c09bd..b7fddfaa2 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -4,6 +4,14 @@ Our bot is one of the most important tools we have for running our community. As
_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you can take a look at the [Additional resources](#additional-resources) section at the bottom of this page._
+### Table of contents:
+- [Tools](#tools)
+- [Running tests](#running-tests)
+- [Writing tests](#writing-tests)
+- [Mocking](#mocking)
+- [Some considerations](#some-considerations)
+- [Additional resources](#additional-resources)
+
## Tools
We are using the following modules and packages for our unit tests:
@@ -11,15 +19,43 @@ We are using the following modules and packages for our unit tests:
- [unittest](https://docs.python.org/3/library/unittest.html) (standard library)
- [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) (standard library)
- [coverage.py](https://coverage.readthedocs.io/en/stable/)
+- [pytest-cov](https://pytest-cov.readthedocs.io/en/latest/index.html)
+
+We also use the following package as a test runner:
+- [pytest](https://docs.pytest.org/en/6.2.x/)
-To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided two "scripts" shortcuts:
+To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided the following "script" shortcuts:
-- `poetry run task test` will run `unittest` with `coverage.py`
+- `poetry run task test-nocov` will run `pytest`.
+- `poetry run task test` will run `pytest` with `pytest-cov`.
- `poetry run task test path/to/test.py` will run a specific test.
- `poetry run task report` will generate a coverage report of the tests you've run with `poetry run task test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report.
If you want a coverage report, make sure to run the tests with `poetry run task test` *first*.
+## Running tests
+There are multiple ways to run the tests, which one you use will be determined by your goal, and stage in development.
+
+When actively developing, you'll most likely be working on one portion of the codebase, and as a result, won't need to run the entire test suite.
+To run just one file, and save time, you can use the following command:
+```shell
+poetry run task test-nocov <path/to/file.py>
+```
+
+For example:
+```shell
+poetry run task test-nocov tests/bot/exts/test_cogs.py
+```
+will run the test suite in the `test_cogs` file.
+
+If you'd like to collect coverage as well, you can append `--cov` to the command above.
+
+
+If you're done and are preparing to commit and push your code, it's a good idea to run the entire test suite as a sanity check:
+```shell
+poetry run task test
+```
+
## Writing tests
Since consistency is an important consideration for collaborative projects, we have written some guidelines on writing tests for the bot. In addition to these guidelines, it's a good idea to look at the existing code base for examples (e.g., [`test_converters.py`](/tests/bot/test_converters.py)).
diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py
index 3393c6cdc..06d78de9d 100644
--- a/tests/bot/exts/filters/test_antimalware.py
+++ b/tests/bot/exts/filters/test_antimalware.py
@@ -104,24 +104,39 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION)
async def test_txt_file_redirect_embed_description(self):
- """A message containing a .txt file should result in the correct embed."""
- attachment = MockAttachment(filename="python.txt")
- self.message.attachments = [attachment]
- self.message.channel.send = AsyncMock()
- antimalware.TXT_EMBED_DESCRIPTION = Mock()
- antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test"
-
- await self.cog.on_message(self.message)
- self.message.channel.send.assert_called_once()
- args, kwargs = self.message.channel.send.call_args
- embed = kwargs.pop("embed")
- cmd_channel = self.bot.get_channel(Channels.bot_commands)
+ """A message containing a .txt/.json/.csv file should result in the correct embed."""
+ test_values = (
+ ("text", ".txt"),
+ ("json", ".json"),
+ ("csv", ".csv"),
+ )
- self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value)
- antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention)
+ for file_name, disallowed_extension in test_values:
+ with self.subTest(file_name=file_name, disallowed_extension=disallowed_extension):
+
+ attachment = MockAttachment(filename=f"{file_name}{disallowed_extension}")
+ self.message.attachments = [attachment]
+ self.message.channel.send = AsyncMock()
+ antimalware.TXT_EMBED_DESCRIPTION = Mock()
+ antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test"
+
+ await self.cog.on_message(self.message)
+ self.message.channel.send.assert_called_once()
+ args, kwargs = self.message.channel.send.call_args
+ embed = kwargs.pop("embed")
+ cmd_channel = self.bot.get_channel(Channels.bot_commands)
+
+ self.assertEqual(
+ embed.description,
+ antimalware.TXT_EMBED_DESCRIPTION.format.return_value
+ )
+ antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(
+ blocked_extension=disallowed_extension,
+ cmd_channel_mention=cmd_channel.mention
+ )
async def test_other_disallowed_extension_embed_description(self):
- """Test the description for a non .py/.txt disallowed extension."""
+ """Test the description for a non .py/.txt/.json/.csv disallowed extension."""
attachment = MockAttachment(filename="python.disallowed")
self.message.attachments = [attachment]
self.message.channel.send = AsyncMock()
diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py
index ee9ff650c..50a717bb5 100644
--- a/tests/bot/exts/moderation/infraction/test_utils.py
+++ b/tests/bot/exts/moderation/infraction/test_utils.py
@@ -137,7 +137,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
type="Ban",
- expires="2020-02-26 09:20 (23 hours and 59 minutes)",
+ expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC",
reason="No reason provided."
),
colour=Colours.soft_red,
@@ -193,7 +193,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
type="Mute",
- expires="2020-02-26 09:20 (23 hours and 59 minutes)",
+ expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC",
reason="Test"
),
colour=Colours.soft_red,