From ab0d44b8e013694c2e92af51c6fdb8ed9239c331 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Wed, 25 Dec 2019 18:20:16 +0100 Subject: Hardcode SIGKILL value It allows the cog to also work on Windows, because of Signals.SIGKILL not being defined on this platform --- bot/cogs/snekbox.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index da33e27b2..e9e5465ad 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -36,6 +36,8 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 1000 EVAL_ROLES = (Roles.helpers, Roles.moderator, Roles.admin, Roles.owner, Roles.rockstars, Roles.partners) +SIGKILL = 9 + class Snekbox(Cog): """Safe evaluation of Python code using Snekbox.""" @@ -101,7 +103,7 @@ class Snekbox(Cog): if returncode is None: msg = "Your eval job has failed" error = stdout.strip() - elif returncode == 128 + Signals.SIGKILL: + elif returncode == 128 + SIGKILL: msg = "Your eval job timed out or ran out of memory" elif returncode == 255: msg = "Your eval job has failed" -- cgit v1.2.3 From e53492ed9485e169b2fa471635c2ea624ec1d532 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Thu, 26 Dec 2019 11:17:48 +0100 Subject: Correct eval output to include the 11th line --- bot/cogs/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index e9e5465ad..00b8618e2 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -154,7 +154,7 @@ class Snekbox(Cog): lines = output.count("\n") if lines > 0: - output = output.split("\n")[:10] # Only first 10 cause the rest is truncated anyway + output = output.split("\n")[:11] # Only first 11 cause the rest is truncated anyway output = (f"{i:03d} | {line}" for i, line in enumerate(output, 1)) output = "\n".join(output) -- cgit v1.2.3 From b5730e0b07a4eb886710d71648ba4c0ffb4ebf79 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 28 Jan 2020 14:50:58 +0000 Subject: Don't strip whitespaces during snekbox formatting It could lead to a misleading result if it is stripped. --- bot/cogs/snekbox.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 00b8618e2..81951efd3 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -137,7 +137,7 @@ class Snekbox(Cog): """ log.trace("Formatting output...") - output = output.strip(" \n") + output = output.rstrip("\n") original_output = output # To be uploaded to a pasting service if needed paste_link = None @@ -171,7 +171,6 @@ class Snekbox(Cog): if truncated: paste_link = await self.upload_output(original_output) - output = output.strip() if not output: output = "[No output]" -- cgit v1.2.3 From 6991e5fab13893c05d6f220e71f6ffc71509c1aa Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 29 Jan 2020 19:19:58 +0000 Subject: Re-eval snippet with emoji reaction If the eval message is edited after less than 10 seconds, an emoji is added to the message, and if the user adds the same, the snippet is re-evaluated. This make easier to correct snipper mistakes. --- bot/cogs/snekbox.py | 69 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 81951efd3..1688c0278 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -1,3 +1,4 @@ +import asyncio import datetime import logging import re @@ -200,32 +201,54 @@ class Snekbox(Cog): log.info(f"Received code from {ctx.author} for evaluation:\n{code}") - self.jobs[ctx.author.id] = datetime.datetime.now() - code = self.prepare_input(code) + while True: + self.jobs[ctx.author.id] = datetime.datetime.now() + code = self.prepare_input(code) - try: - async with ctx.typing(): - results = await self.post_eval(code) - msg, error = self.get_results_message(results) - - if error: - output, paste_link = error, None - else: - output, paste_link = await self.format_output(results["stdout"]) - - icon = self.get_status_emoji(results) - msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```" - if paste_link: - msg = f"{msg}\nFull output: {paste_link}" - - response = await ctx.send(msg) - self.bot.loop.create_task( - wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + try: + async with ctx.typing(): + results = await self.post_eval(code) + msg, error = self.get_results_message(results) + + if error: + output, paste_link = error, None + else: + output, paste_link = await self.format_output(results["stdout"]) + + icon = self.get_status_emoji(results) + msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```" + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + + response = await ctx.send(msg) + self.bot.loop.create_task( + wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + ) + + log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") + finally: + del self.jobs[ctx.author.id] + + try: + _, new_message = await self.bot.wait_for( + 'message_edit', + check=lambda o, n: n.id == ctx.message.id and o.content != n.content, + timeout=10 + ) + await ctx.message.add_reaction('๐Ÿ”') + await self.bot.wait_for( + 'reaction_add', + check=lambda r, u: r.message.id == ctx.message.id and u.id == ctx.author.id and str(r) == '๐Ÿ”', + timeout=10 ) - log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") - finally: - del self.jobs[ctx.author.id] + log.info(f"Re-evaluating message {ctx.message.id}") + code = new_message.content.split(' ', maxsplit=1)[1] + await ctx.message.clear_reactions() + await response.delete() + except asyncio.TimeoutError: + await ctx.message.clear_reactions() + return def setup(bot: Bot) -> None: -- cgit v1.2.3 From 032e1b80934194b85c43d67f3a26cf51b972696d Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 9 Feb 2020 17:03:35 +0100 Subject: Use actual functions instead of lambdas for bot.wait_for The use of lambdas made the functions hard to test, this new format allows us to easily test those functions and document them. --- bot/cogs/snekbox.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 1688c0278..3fc8d9937 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -3,9 +3,11 @@ import datetime import logging import re import textwrap +from functools import partial from signal import Signals from typing import Optional, Tuple +from discord import Message, Reaction, User from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot @@ -232,13 +234,13 @@ class Snekbox(Cog): try: _, new_message = await self.bot.wait_for( 'message_edit', - check=lambda o, n: n.id == ctx.message.id and o.content != n.content, + check=partial(predicate_eval_message_edit, ctx), timeout=10 ) await ctx.message.add_reaction('๐Ÿ”') await self.bot.wait_for( 'reaction_add', - check=lambda r, u: r.message.id == ctx.message.id and u.id == ctx.author.id and str(r) == '๐Ÿ”', + check=partial(predicate_eval_emoji_reaction, ctx), timeout=10 ) @@ -251,6 +253,16 @@ class Snekbox(Cog): return +def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: + """Return True if the edited message is the context message and the content was indeed modified.""" + return new_msg.id == ctx.message.id and old_msg.content != new_msg.content + + +def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: + """Return True if the reaction ๐Ÿ” was added by the context message author on this message.""" + return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == '๐Ÿ”' + + def setup(bot: Bot) -> None: """Load the Snekbox cog.""" bot.add_cog(Snekbox(bot)) -- cgit v1.2.3 From adf63e65a0b861cb02a6e8cc1e5b2c2c09e57726 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 9 Feb 2020 17:05:09 +0100 Subject: Create an AsyncContextManagerMock mock for testing asynchronous context managers It can be used to test aiohttp request functions, since they are async context managers --- tests/helpers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 5df796c23..6aee8623f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -127,6 +127,18 @@ class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): return super().__call__(*args, **kwargs) +class AsyncContextManagerMock(unittest.mock.MagicMock): + def __init__(self, return_value: Any): + super().__init__() + self._return_value = return_value + + async def __aenter__(self): + return self._return_value + + async def __aexit__(self, *args): + pass + + class AsyncIteratorMock: """ A class to mock asynchronous iterators. -- cgit v1.2.3 From 8c8f8dd50f376c3398c04e7bbe76b5028b69ff83 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 9 Feb 2020 17:11:47 +0100 Subject: Write tests for bot/cogs/test_snekbox.py --- tests/bot/cogs/test_snekbox.py | 363 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 tests/bot/cogs/test_snekbox.py diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py new file mode 100644 index 000000000..293efed0f --- /dev/null +++ b/tests/bot/cogs/test_snekbox.py @@ -0,0 +1,363 @@ +import asyncio +import logging +import unittest +from unittest.mock import MagicMock, Mock, call, patch + +from bot.cogs import snekbox +from bot.cogs.snekbox import Snekbox +from bot.constants import URLs +from tests.helpers import ( + AsyncContextManagerMock, AsyncMock, MockBot, MockContext, MockMessage, MockReaction, MockUser, async_test +) + + +class SnekboxTests(unittest.TestCase): + def setUp(self): + """Add mocked bot and cog to the instance.""" + self.bot = MockBot() + + self.mocked_post = MagicMock() + self.mocked_post.json = AsyncMock() + self.bot.http_session.post = MagicMock(return_value=AsyncContextManagerMock(self.mocked_post)) + + self.cog = Snekbox(bot=self.bot) + + @async_test + async def test_post_eval(self): + """Post the eval code to the URLs.snekbox_eval_api endpoint.""" + await self.cog.post_eval("import random") + self.bot.http_session.post.assert_called_once_with( + URLs.snekbox_eval_api, + json={"input": "import random"}, + raise_for_status=True + ) + + @async_test + async def test_upload_output_reject_too_long(self): + """Reject output longer than MAX_PASTE_LEN.""" + self.assertEqual(await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)), "too long to upload") + + @async_test + async def test_upload_output(self): + """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" + key = "RainbowDash" + self.mocked_post.json.return_value = {"key": key} + + self.assertEqual( + await self.cog.upload_output("My awesome output"), + URLs.paste_service.format(key=key) + ) + self.bot.http_session.post.assert_called_once_with( + URLs.paste_service.format(key="documents"), + data="My awesome output", + raise_for_status=True + ) + + @async_test + async def test_upload_output_gracefully_fallback_if_exception_during_request(self): + """Output upload gracefully fallback if the upload fail.""" + self.mocked_post.json.side_effect = Exception + log = logging.getLogger("bot.cogs.snekbox") + with self.assertLogs(logger=log, level='ERROR'): + await self.cog.upload_output('My awesome output!') + + @async_test + async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): + """Output upload gracefully fallback if there is no key entry in the response body.""" + self.mocked_post.json.return_value = {} + self.assertEqual((await self.cog.upload_output('My awesome output!')), None) + + def test_prepare_input(self): + cases = ( + ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), + ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), + ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), + ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), + ) + for case, expected, testname in cases: + with self.subTest(msg=f'Extract code from {testname}.', case=case, expected=expected): + self.assertEqual(self.cog.prepare_input(case), expected) + + def test_get_results_message(self): + """Return error and message according to the eval result.""" + cases = ( + ('ERROR', None, ('Your eval job has failed', 'ERROR')), + ('', 128 + snekbox.SIGKILL, ('Your eval job timed out or ran out of memory', '')), + ('', 255, ('Your eval job has failed', 'A fatal NsJail error occurred')) + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + self.assertEqual(self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}), expected) + + @patch('bot.cogs.snekbox.Signals', side_effect=ValueError) + def test_get_results_message_invalid_signal(self, mock_Signals: Mock): + self.assertEqual( + self.cog.get_results_message({'stdout': '', 'returncode': 127}), + ('Your eval job has completed with return code 127', '') + ) + + @patch('bot.cogs.snekbox.Signals') + def test_get_results_message_valid_signal(self, mock_Signals: Mock): + mock_Signals.return_value.name = 'SIGTEST' + self.assertEqual( + self.cog.get_results_message({'stdout': '', 'returncode': 127}), + ('Your eval job has completed with return code 127 (SIGTEST)', '') + ) + + def test_get_status_emoji(self): + """Return emoji according to the eval result.""" + cases = ( + ('', -1, ':warning:'), + ('Hello world!', 0, ':white_check_mark:'), + ('Invalid beard size', -1, ':x:') + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + self.assertEqual(self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}), expected) + + @async_test + async def test_format_output(self): + """Test output formatting.""" + self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') + + too_many_lines = ( + '001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n' + '007 | n\n008 | g\n009 | b\n010 | e\n011 | a\n... (truncated - too many lines)' + ) + too_long_too_many_lines = ( + "\n".join( + f"{i:03d} | {line}" for i, line in enumerate(['verylongbeard' * 10] * 15, 1) + )[:1000] + "\n... (truncated - too long, too many lines)" + ) + + cases = ( + ('', ('[No output]', None), 'No output'), + ('My awesome output', ('My awesome output', None), 'One line output'), + ('<@', ("<@\u200B", None), r'Convert <@ to <@\u200B'), + (' Date: Sat, 15 Feb 2020 19:57:58 -0800 Subject: Scheduler: fix #754 - only suppress CancelledError --- bot/utils/scheduling.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index ee6c0a8e6..8d4721d70 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -63,12 +63,13 @@ def create_task(loop: asyncio.AbstractEventLoop, coro_or_future: Union[Coroutine """Creates an asyncio.Task object from a coroutine or future object.""" task: asyncio.Task = asyncio.ensure_future(coro_or_future, loop=loop) - # Silently ignore exceptions in a callback (handles the CancelledError nonsense) - task.add_done_callback(_silent_exception) + # Silently ignore CancelledError in a callback + task.add_done_callback(_suppress_cancelled_error) return task -def _silent_exception(future: asyncio.Future) -> None: - """Suppress future's exception.""" - with contextlib.suppress(Exception): - future.exception() +def _suppress_cancelled_error(future: asyncio.Future) -> None: + """Suppress future's CancelledError exception.""" + if future.cancelled(): + with contextlib.suppress(asyncio.CancelledError): + future.exception() -- cgit v1.2.3 From f905f73451730fb5b83b441f8d32748acef374e0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 20:07:26 -0800 Subject: Scheduler: remove create_task function It's redundant because the done callback only takes a single line to add and can be added in schedule_task(). * Use Task as the type hint rather than Future for _suppress_cancelled_error() --- bot/utils/scheduling.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 8d4721d70..7b055f5e7 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -2,7 +2,7 @@ import asyncio import contextlib import logging from abc import abstractmethod -from typing import Coroutine, Dict, Union +from typing import Dict from bot.utils import CogABCMeta @@ -41,7 +41,8 @@ class Scheduler(metaclass=CogABCMeta): ) return - task: asyncio.Task = create_task(loop, self._scheduled_task(task_data)) + task = loop.create_task(self._scheduled_task(task_data)) + task.add_done_callback(_suppress_cancelled_error) self.scheduled_tasks[task_id] = task log.debug(f"{self.cog_name}: scheduled task #{task_id}.") @@ -59,17 +60,8 @@ class Scheduler(metaclass=CogABCMeta): del self.scheduled_tasks[task_id] -def create_task(loop: asyncio.AbstractEventLoop, coro_or_future: Union[Coroutine, asyncio.Future]) -> asyncio.Task: - """Creates an asyncio.Task object from a coroutine or future object.""" - task: asyncio.Task = asyncio.ensure_future(coro_or_future, loop=loop) - - # Silently ignore CancelledError in a callback - task.add_done_callback(_suppress_cancelled_error) - return task - - -def _suppress_cancelled_error(future: asyncio.Future) -> None: - """Suppress future's CancelledError exception.""" - if future.cancelled(): +def _suppress_cancelled_error(task: asyncio.Task) -> None: + """Suppress a task's CancelledError exception.""" + if task.cancelled(): with contextlib.suppress(asyncio.CancelledError): - future.exception() + task.exception() -- cgit v1.2.3 From f5cd7e357de26c81b462b8935ec4bdaa032429fc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 20:10:42 -0800 Subject: Scheduler: correct schedule_task's docstring --- bot/utils/scheduling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 7b055f5e7..adf10d683 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -33,7 +33,7 @@ class Scheduler(metaclass=CogABCMeta): """ Schedules a task. - `task_data` is passed to `Scheduler._scheduled_expiration` + `task_data` is passed to the `Scheduler._scheduled_task()` coroutine. """ if task_id in self.scheduled_tasks: log.debug( -- cgit v1.2.3 From 0d05be37564b1ec8babd688fb348c2c13eeb9fa2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 20:16:30 -0800 Subject: Scheduler: remove loop parameter from schedule_task asyncio.create_task() exists and will already use the running loop in the current thread. Because there is no intention of using a different loop in a different thread anywhere in the program for the foreseeable future, the loop parameter is redundant. --- bot/cogs/moderation/management.py | 4 +--- bot/cogs/moderation/scheduler.py | 4 ++-- bot/cogs/moderation/superstarify.py | 2 +- bot/cogs/reminders.py | 11 +++-------- bot/utils/scheduling.py | 4 ++-- 5 files changed, 9 insertions(+), 16 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index f2964cd78..279c8b809 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -1,4 +1,3 @@ -import asyncio import logging import textwrap import typing as t @@ -133,8 +132,7 @@ class ModManagement(commands.Cog): # If the infraction was not marked as permanent, schedule a new expiration task if request_data['expires_at']: - loop = asyncio.get_event_loop() - self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction) + self.infractions_cog.schedule_task(new_infraction['id'], new_infraction) log_text += f""" Previous expiry: {old_infraction['expires_at'] or "Permanent"} diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index e14c302cb..62b040d1f 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -48,7 +48,7 @@ class InfractionScheduler(Scheduler): ) for infraction in infractions: if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: - self.schedule_task(self.bot.loop, infraction["id"], infraction) + self.schedule_task(infraction["id"], infraction) async def reapply_infraction( self, @@ -150,7 +150,7 @@ class InfractionScheduler(Scheduler): await action_coro if expiry: # Schedule the expiration of the infraction. - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) + self.schedule_task(infraction["id"], infraction) except discord.HTTPException as e: # Accordingly display that applying the infraction failed. confirm_msg = f":x: failed to apply" diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 050c847ac..d94ee6891 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -145,7 +145,7 @@ class Superstarify(InfractionScheduler, Cog): log.debug(f"Changing nickname of {member} to {forced_nick}.") self.mod_log.ignore(constants.Event.member_update, member.id) await member.edit(nick=forced_nick, reason=reason) - self.schedule_task(ctx.bot.loop, id_, infraction) + self.schedule_task(id_, infraction) # Send a DM to the user to notify them of their new infraction. await utils.notify_infraction( diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 45bf9a8f4..d96dedd20 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -1,4 +1,3 @@ -import asyncio import logging import random import textwrap @@ -42,7 +41,6 @@ class Reminders(Scheduler, Cog): ) now = datetime.utcnow() - loop = asyncio.get_event_loop() for reminder in response: remind_at = datetime.fromisoformat(reminder['expiration'][:-1]) @@ -53,7 +51,7 @@ class Reminders(Scheduler, Cog): await self.send_reminder(reminder, late) else: - self.schedule_task(loop, reminder["id"], reminder) + self.schedule_task(reminder["id"], reminder) @staticmethod async def _send_confirmation(ctx: Context, on_success: str) -> None: @@ -88,10 +86,8 @@ class Reminders(Scheduler, Cog): async def _reschedule_reminder(self, reminder: dict) -> None: """Reschedule a reminder object.""" - loop = asyncio.get_event_loop() - self.cancel_task(reminder["id"]) - self.schedule_task(loop, reminder["id"], reminder) + self.schedule_task(reminder["id"], reminder) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" @@ -185,8 +181,7 @@ class Reminders(Scheduler, Cog): on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!" ) - loop = asyncio.get_event_loop() - self.schedule_task(loop, reminder["id"], reminder) + self.schedule_task(reminder["id"], reminder) @remind_group.command(name="list") async def list_reminders(self, ctx: Context) -> Optional[Message]: diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index adf10d683..a16900066 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -29,7 +29,7 @@ class Scheduler(metaclass=CogABCMeta): then make a site API request to delete the reminder from the database. """ - def schedule_task(self, loop: asyncio.AbstractEventLoop, task_id: str, task_data: dict) -> None: + def schedule_task(self, task_id: str, task_data: dict) -> None: """ Schedules a task. @@ -41,7 +41,7 @@ class Scheduler(metaclass=CogABCMeta): ) return - task = loop.create_task(self._scheduled_task(task_data)) + task = asyncio.create_task(self._scheduled_task(task_data)) task.add_done_callback(_suppress_cancelled_error) self.scheduled_tasks[task_id] = task -- cgit v1.2.3 From 6b7c0a7a74460ee96c5ce574bf042f3de38dd685 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 20:24:48 -0800 Subject: Scheduler: raise task exceptions besides CancelledError Explicitly retrieves the task's exception, which will raise the exception if one exists. * Rename _suppress_cancelled_error to _handle_task_exception --- bot/utils/scheduling.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index a16900066..df46ccdd9 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -13,8 +13,9 @@ class Scheduler(metaclass=CogABCMeta): """Task scheduler.""" def __init__(self): + # Keep track of the child cog's name so the logs are clear. + self.cog_name = self.__class__.__name__ - self.cog_name = self.__class__.__name__ # keep track of the child cog's name so the logs are clear. self.scheduled_tasks: Dict[str, asyncio.Task] = {} @abstractmethod @@ -42,7 +43,7 @@ class Scheduler(metaclass=CogABCMeta): return task = asyncio.create_task(self._scheduled_task(task_data)) - task.add_done_callback(_suppress_cancelled_error) + task.add_done_callback(_handle_task_exception) self.scheduled_tasks[task_id] = task log.debug(f"{self.cog_name}: scheduled task #{task_id}.") @@ -60,8 +61,10 @@ class Scheduler(metaclass=CogABCMeta): del self.scheduled_tasks[task_id] -def _suppress_cancelled_error(task: asyncio.Task) -> None: - """Suppress a task's CancelledError exception.""" +def _handle_task_exception(task: asyncio.Task) -> None: + """Raise the task's exception, if any, unless the task is cancelled and has a CancelledError.""" if task.cancelled(): with contextlib.suppress(asyncio.CancelledError): task.exception() + else: + task.exception() -- cgit v1.2.3 From 687b6404e8976ffdc67ba9492a4355819f06a2f7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 21:17:22 -0800 Subject: Scheduler: cancel the task in the callback This design makes more sense and is more convenient than requiring tasks to be responsible for cancelling themselves. * Rename _handle_task_exception to _task_done_callback * Add trace logging --- bot/cogs/reminders.py | 6 +++--- bot/utils/scheduling.py | 36 +++++++++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index d96dedd20..603b627fb 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -74,9 +74,6 @@ class Reminders(Scheduler, Cog): log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") await self._delete_reminder(reminder_id) - # Now we can begone with it from our schedule list. - self.cancel_task(reminder_id) - async def _delete_reminder(self, reminder_id: str) -> None: """Delete a reminder from the database, given its ID, and cancel the running task.""" await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) @@ -86,7 +83,10 @@ class Reminders(Scheduler, Cog): async def _reschedule_reminder(self, reminder: dict) -> None: """Reschedule a reminder object.""" + log.trace(f"Cancelling old task #{reminder['id']}") self.cancel_task(reminder["id"]) + + log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_task(reminder["id"], reminder) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index df46ccdd9..40d26249f 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -2,6 +2,7 @@ import asyncio import contextlib import logging from abc import abstractmethod +from functools import partial from typing import Dict from bot.utils import CogABCMeta @@ -36,6 +37,8 @@ class Scheduler(metaclass=CogABCMeta): `task_data` is passed to the `Scheduler._scheduled_task()` coroutine. """ + log.trace(f"{self.cog_name}: scheduling task #{task_id}...") + if task_id in self.scheduled_tasks: log.debug( f"{self.cog_name}: did not schedule task #{task_id}; task was already scheduled." @@ -43,13 +46,15 @@ class Scheduler(metaclass=CogABCMeta): return task = asyncio.create_task(self._scheduled_task(task_data)) - task.add_done_callback(_handle_task_exception) + task.add_done_callback(partial(self._task_done_callback, task_id)) self.scheduled_tasks[task_id] = task - log.debug(f"{self.cog_name}: scheduled task #{task_id}.") + log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.") def cancel_task(self, task_id: str) -> None: """Un-schedules a task.""" + log.trace(f"{self.cog_name}: cancelling task #{task_id}...") + task = self.scheduled_tasks.get(task_id) if task is None: @@ -57,14 +62,27 @@ class Scheduler(metaclass=CogABCMeta): return task.cancel() - log.debug(f"{self.cog_name}: unscheduled task #{task_id}.") + log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") del self.scheduled_tasks[task_id] + def _task_done_callback(self, task_id: str, task: asyncio.Task) -> None: + """ + Unschedule the task and raise its exception if one exists. + + If the task was cancelled, the CancelledError is retrieved and suppressed. In this case, + the task is already assumed to have been unscheduled. + """ + log.trace(f"{self.cog_name}: performing done callback for task #{task_id} {id(task)}") + + if task.cancelled(): + with contextlib.suppress(asyncio.CancelledError): + task.exception() + else: + # Check if it exists to avoid logging a warning. + if task_id in self.scheduled_tasks: + # Only cancel if the task is not cancelled to avoid a race condition when a new + # task is scheduled using the same ID. Reminders do this when re-scheduling after + # editing. + self.cancel_task(task_id) -def _handle_task_exception(task: asyncio.Task) -> None: - """Raise the task's exception, if any, unless the task is cancelled and has a CancelledError.""" - if task.cancelled(): - with contextlib.suppress(asyncio.CancelledError): task.exception() - else: - task.exception() -- cgit v1.2.3 From 6edae6cda82add4d0bf538e916ce571432f83ab1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 21:21:51 -0800 Subject: Moderation: avoid prematurely cancelling deactivation task Because deactivate_infraction() explicitly cancels the scheduled task, it now runs in a separate task to avoid prematurely cancelling itself. --- bot/cogs/moderation/scheduler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 62b040d1f..7d401c7ff 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -415,4 +415,6 @@ class InfractionScheduler(Scheduler): expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) await time.wait_until(expiry) - await self.deactivate_infraction(infraction) + # Because deactivate_infraction() explicitly cancels this scheduled task, it runs in + # a separate task to avoid prematurely cancelling itself. + self.bot.loop.create_task(self.deactivate_infraction(infraction)) -- cgit v1.2.3 From f09d6b0646edb62806b60b145986b7cc680fd77c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 21:34:38 -0800 Subject: Scheduler: make _scheduled_tasks private Main concern is someone trying to cancel a task directly. The workaround for the race condition relies on the task only being cancelled via Scheduler.cancel_task(), particularly because it removes the task from the dictionary. The done callback will not remove from the dictionary if it sees the task has already been cancelled. So it's a bad idea to cancel tasks directly... --- bot/utils/scheduling.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 40d26249f..0d66952eb 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -17,7 +17,7 @@ class Scheduler(metaclass=CogABCMeta): # Keep track of the child cog's name so the logs are clear. self.cog_name = self.__class__.__name__ - self.scheduled_tasks: Dict[str, asyncio.Task] = {} + self._scheduled_tasks: Dict[str, asyncio.Task] = {} @abstractmethod async def _scheduled_task(self, task_object: dict) -> None: @@ -39,7 +39,7 @@ class Scheduler(metaclass=CogABCMeta): """ log.trace(f"{self.cog_name}: scheduling task #{task_id}...") - if task_id in self.scheduled_tasks: + if task_id in self._scheduled_tasks: log.debug( f"{self.cog_name}: did not schedule task #{task_id}; task was already scheduled." ) @@ -48,14 +48,14 @@ class Scheduler(metaclass=CogABCMeta): task = asyncio.create_task(self._scheduled_task(task_data)) task.add_done_callback(partial(self._task_done_callback, task_id)) - self.scheduled_tasks[task_id] = task + self._scheduled_tasks[task_id] = task log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.") def cancel_task(self, task_id: str) -> None: """Un-schedules a task.""" log.trace(f"{self.cog_name}: cancelling task #{task_id}...") - task = self.scheduled_tasks.get(task_id) + task = self._scheduled_tasks.get(task_id) if task is None: log.warning(f"{self.cog_name}: Failed to unschedule {task_id} (no task found).") @@ -63,7 +63,7 @@ class Scheduler(metaclass=CogABCMeta): task.cancel() log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") - del self.scheduled_tasks[task_id] + del self._scheduled_tasks[task_id] def _task_done_callback(self, task_id: str, task: asyncio.Task) -> None: """ @@ -79,7 +79,7 @@ class Scheduler(metaclass=CogABCMeta): task.exception() else: # Check if it exists to avoid logging a warning. - if task_id in self.scheduled_tasks: + if task_id in self._scheduled_tasks: # Only cancel if the task is not cancelled to avoid a race condition when a new # task is scheduled using the same ID. Reminders do this when re-scheduling after # editing. -- cgit v1.2.3 From 6f25814aee1794fdabce6fef97d0d776121d5535 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 11:41:56 -0800 Subject: Moderation: fix member not found error not being shown --- bot/cogs/moderation/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index f4e296df9..9ea17b2b3 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -313,6 +313,6 @@ 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: + if discord.User in error.converters or discord.Member in error.converters: await ctx.send(str(error.errors[0])) error.handled = True -- cgit v1.2.3 From a83d2683f72a750b1946df913749a5c4257ebb16 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 11:52:54 -0800 Subject: Error handler: create separate function to handle CheckFailure --- bot/cogs/error_handler.py | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 52893b2ee..bd47eecf8 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -100,21 +100,9 @@ class ErrorHandler(Cog): f"Command {command} invoked by {ctx.message.author} with error " f"{e.__class__.__name__}: {e}" ) - elif isinstance(e, NoPrivateMessage): - await ctx.send("Sorry, this command can't be used in a private message!") - elif isinstance(e, BotMissingPermissions): - await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") - log.warning( - f"The bot is missing permissions to execute command {command}: {e.missing_perms}" - ) - elif isinstance(e, MissingPermissions): - log.debug( - f"{ctx.message.author} is missing permissions to invoke command {command}: " - f"{e.missing_perms}" - ) - elif isinstance(e, InChannelCheckFailure): - await ctx.send(e) - elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)): + elif isinstance(e, CheckFailure): + await self.handle_check_failure(ctx, e) + elif isinstance(e, (CommandOnCooldown, DisabledCommand)): log.debug( f"Command {command} invoked by {ctx.message.author} with error " f"{e.__class__.__name__}: {e}" @@ -138,8 +126,34 @@ class ErrorHandler(Cog): else: await self.handle_unexpected_error(ctx, e.original) else: + # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) + @staticmethod + async def handle_check_failure(ctx: Context, e: CheckFailure) -> None: + """Handle CheckFailure exceptions and its children.""" + command = ctx.command + + if isinstance(e, NoPrivateMessage): + await ctx.send("Sorry, this command can't be used in a private message!") + elif isinstance(e, BotMissingPermissions): + await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") + log.warning( + f"The bot is missing permissions to execute command {command}: {e.missing_perms}" + ) + elif isinstance(e, MissingPermissions): + log.debug( + f"{ctx.message.author} is missing permissions to invoke command {command}: " + f"{e.missing_perms}" + ) + elif isinstance(e, InChannelCheckFailure): + await ctx.send(e) + else: + log.debug( + f"Command {command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) + @staticmethod async def handle_unexpected_error(ctx: Context, e: CommandError) -> None: """Generic handler for errors without an explicit handler.""" -- cgit v1.2.3 From eab4b16ccb2d5b6f3d0a8765e8741fe88fb03e27 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 11:58:48 -0800 Subject: Error handler: create separate function to handle ResponseCodeError --- bot/cogs/error_handler.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index bd47eecf8..97124cb15 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -109,20 +109,7 @@ class ErrorHandler(Cog): ) elif isinstance(e, CommandInvokeError): if isinstance(e.original, ResponseCodeError): - status = e.original.response.status - - if status == 404: - await ctx.send("There does not seem to be anything matching your query.") - elif status == 400: - content = await e.original.response.json() - log.debug(f"API responded with 400 for command {command}: %r.", content) - await ctx.send("According to the API, your request is malformed.") - elif 500 <= status < 600: - await ctx.send("Sorry, there seems to be an internal issue with the API.") - log.warning(f"API responded with {status} for command {command}") - else: - await ctx.send(f"Got an unexpected status code from the API (`{status}`).") - log.warning(f"Unexpected API response for command {command}: {status}") + await self.handle_api_error(ctx, e.original) else: await self.handle_unexpected_error(ctx, e.original) else: @@ -154,6 +141,22 @@ class ErrorHandler(Cog): f"{e.__class__.__name__}: {e}" ) + @staticmethod + async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: + """Handle ResponseCodeError exceptions.""" + if e.status == 404: + await ctx.send("There does not seem to be anything matching your query.") + elif e.status == 400: + content = await e.response.json() + log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) + await ctx.send("According to the API, your request is malformed.") + 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}") + 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}") + @staticmethod async def handle_unexpected_error(ctx: Context, e: CommandError) -> None: """Generic handler for errors without an explicit handler.""" -- cgit v1.2.3 From 29e3c3e46242866820b9c4461378ed4b2e3afb47 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 12:09:24 -0800 Subject: Error handler: log unhandled exceptions instead of re-raising --- bot/cogs/error_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 97124cb15..5eef045e8 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -165,9 +165,9 @@ class ErrorHandler(Cog): f"```{e.__class__.__name__}: {e}```" ) log.error( - f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}" + f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", + exc_info=e ) - raise e def setup(bot: Bot) -> None: -- cgit v1.2.3 From 806c69f78c5751f6dc93bd8dcc6fff95436fe0ed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 12:19:52 -0800 Subject: Error handler: move tag retrieval to a separate function --- bot/cogs/error_handler.py | 46 ++++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 5eef045e8..7078d425d 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -72,24 +72,8 @@ class ErrorHandler(Cog): # Try to look for a tag with the command's name if the command isn't found. if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): - if not ctx.channel.id == Channels.verification: - tags_get_command = self.bot.get_command("tags get") - ctx.invoked_from_error_handler = True - - log_msg = "Cancelling attempt to fall back to a tag due to failed checks." - try: - if not await tags_get_command.can_run(ctx): - log.debug(log_msg) - return - except CommandError as tag_error: - log.debug(log_msg) - await self.on_command_error(ctx, tag_error) - return - - # Return to not raise the exception - with contextlib.suppress(ResponseCodeError): - await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) - return + if ctx.channel.id != Channels.verification: + await self.try_get_tag(ctx) elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) @@ -116,6 +100,32 @@ class ErrorHandler(Cog): # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) + async def try_get_tag(self, ctx: Context) -> None: + """ + Attempt to display a tag by interpreting the command name as a tag name. + + The invocation of tags get respects its checks. Any CommandErrors raised will be handled + by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to + the context to prevent infinite recursion in the case of a CommandNotFound exception. + """ + tags_get_command = self.bot.get_command("tags get") + ctx.invoked_from_error_handler = True + + log_msg = "Cancelling attempt to fall back to a tag due to failed checks." + try: + if not await tags_get_command.can_run(ctx): + log.debug(log_msg) + return + except CommandError as tag_error: + log.debug(log_msg) + await self.on_command_error(ctx, tag_error) + return + + # Return to not raise the exception + with contextlib.suppress(ResponseCodeError): + await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + return + @staticmethod async def handle_check_failure(ctx: Context, e: CheckFailure) -> None: """Handle CheckFailure exceptions and its children.""" -- cgit v1.2.3 From fb30fb1427fa26d6cfd54fdb6a80e4e7552d808f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 12:31:42 -0800 Subject: Error handler: move help command retrieval to a separate function --- bot/cogs/error_handler.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 7078d425d..6a0aef13e 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -1,10 +1,12 @@ import contextlib import logging +import typing as t from discord.ext.commands import ( BadArgument, BotMissingPermissions, CheckFailure, + Command, CommandError, CommandInvokeError, CommandNotFound, @@ -53,18 +55,9 @@ class ErrorHandler(Cog): 10. Otherwise, handling is deferred to `handle_unexpected_error` """ command = ctx.command - parent = None - if command is not None: - parent = command.parent - - # Retrieve the help command for the invoked command. - if parent and command: - help_command = (self.bot.get_command("help"), parent.name, command.name) - elif command: - help_command = (self.bot.get_command("help"), command.name) - else: - help_command = (self.bot.get_command("help"),) + # TODO: use ctx.send_help() once PR #519 is merged. + help_command = await self.get_help_command(command) if hasattr(e, "handled"): log.trace(f"Command {command} had its error already handled locally; ignoring.") @@ -100,6 +93,20 @@ class ErrorHandler(Cog): # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) + async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple: + """Return the help command invocation args to display help for `command`.""" + parent = None + if command is not None: + parent = command.parent + + # Retrieve the help command for the invoked command. + if parent and command: + return self.bot.get_command("help"), parent.name, command.name + elif command: + return self.bot.get_command("help"), command.name + else: + return self.bot.get_command("help") + async def try_get_tag(self, ctx: Context) -> None: """ Attempt to display a tag by interpreting the command name as a tag name. -- cgit v1.2.3 From d263f948e57a71e23cf4e04d678a880a130f3884 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 12:44:58 -0800 Subject: Error handler: create separate function to handle UserInputError --- bot/cogs/error_handler.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 6a0aef13e..c7758d946 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -56,9 +56,6 @@ class ErrorHandler(Cog): """ command = ctx.command - # TODO: use ctx.send_help() once PR #519 is merged. - help_command = await self.get_help_command(command) - if hasattr(e, "handled"): log.trace(f"Command {command} had its error already handled locally; ignoring.") return @@ -67,16 +64,8 @@ class ErrorHandler(Cog): if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): if ctx.channel.id != Channels.verification: await self.try_get_tag(ctx) - elif isinstance(e, BadArgument): - await ctx.send(f"Bad argument: {e}\n") - await ctx.invoke(*help_command) elif isinstance(e, UserInputError): - await ctx.send("Something about your input seems off. Check the arguments:") - await ctx.invoke(*help_command) - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) + await self.handle_user_input_error(ctx, e) elif isinstance(e, CheckFailure): await self.handle_check_failure(ctx, e) elif isinstance(e, (CommandOnCooldown, DisabledCommand)): @@ -133,6 +122,22 @@ class ErrorHandler(Cog): await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) return + async def handle_user_input_error(self, ctx: Context, e: UserInputError) -> None: + """Handle UserInputError exceptions and its children.""" + # TODO: use ctx.send_help() once PR #519 is merged. + help_command = await self.get_help_command(ctx.command) + + if isinstance(e, BadArgument): + await ctx.send(f"Bad argument: {e}\n") + await ctx.invoke(*help_command) + else: + await ctx.send("Something about your input seems off. Check the arguments:") + await ctx.invoke(*help_command) + log.debug( + f"Command {ctx.command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) + @staticmethod async def handle_check_failure(ctx: Context, e: CheckFailure) -> None: """Handle CheckFailure exceptions and its children.""" -- cgit v1.2.3 From dbd879e715fe9eadee33d098282cb7b4d941df26 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 13:09:05 -0800 Subject: Error handler: simplify error imports Import the errors module and qualify the error types with it rather than importing a large list of error types. --- bot/cogs/error_handler.py | 44 +++++++++++++++----------------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index c7758d946..c65ada344 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -2,21 +2,7 @@ import contextlib import logging import typing as t -from discord.ext.commands import ( - BadArgument, - BotMissingPermissions, - CheckFailure, - Command, - CommandError, - CommandInvokeError, - CommandNotFound, - CommandOnCooldown, - DisabledCommand, - MissingPermissions, - NoPrivateMessage, - UserInputError, -) -from discord.ext.commands import Cog, Context +from discord.ext.commands import Cog, Command, Context, errors from bot.api import ResponseCodeError from bot.bot import Bot @@ -33,7 +19,7 @@ class ErrorHandler(Cog): self.bot = bot @Cog.listener() - async def on_command_error(self, ctx: Context, e: CommandError) -> None: + async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: """ Provide generic command error handling. @@ -61,19 +47,19 @@ class ErrorHandler(Cog): return # Try to look for a tag with the command's name if the command isn't found. - if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): + if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): if ctx.channel.id != Channels.verification: await self.try_get_tag(ctx) - elif isinstance(e, UserInputError): + elif isinstance(e, errors.UserInputError): await self.handle_user_input_error(ctx, e) - elif isinstance(e, CheckFailure): + elif isinstance(e, errors.CheckFailure): await self.handle_check_failure(ctx, e) - elif isinstance(e, (CommandOnCooldown, DisabledCommand)): + elif isinstance(e, (errors.CommandOnCooldown, errors.DisabledCommand)): log.debug( f"Command {command} invoked by {ctx.message.author} with error " f"{e.__class__.__name__}: {e}" ) - elif isinstance(e, CommandInvokeError): + elif isinstance(e, errors.CommandInvokeError): if isinstance(e.original, ResponseCodeError): await self.handle_api_error(ctx, e.original) else: @@ -112,7 +98,7 @@ class ErrorHandler(Cog): if not await tags_get_command.can_run(ctx): log.debug(log_msg) return - except CommandError as tag_error: + except errors.CommandError as tag_error: log.debug(log_msg) await self.on_command_error(ctx, tag_error) return @@ -122,12 +108,12 @@ class ErrorHandler(Cog): await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) return - async def handle_user_input_error(self, ctx: Context, e: UserInputError) -> None: + async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: """Handle UserInputError exceptions and its children.""" # TODO: use ctx.send_help() once PR #519 is merged. help_command = await self.get_help_command(ctx.command) - if isinstance(e, BadArgument): + if isinstance(e, errors.BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) else: @@ -139,18 +125,18 @@ class ErrorHandler(Cog): ) @staticmethod - async def handle_check_failure(ctx: Context, e: CheckFailure) -> None: + async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: """Handle CheckFailure exceptions and its children.""" command = ctx.command - if isinstance(e, NoPrivateMessage): + if isinstance(e, errors.NoPrivateMessage): await ctx.send("Sorry, this command can't be used in a private message!") - elif isinstance(e, BotMissingPermissions): + elif isinstance(e, errors.BotMissingPermissions): await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") log.warning( f"The bot is missing permissions to execute command {command}: {e.missing_perms}" ) - elif isinstance(e, MissingPermissions): + elif isinstance(e, errors.MissingPermissions): log.debug( f"{ctx.message.author} is missing permissions to invoke command {command}: " f"{e.missing_perms}" @@ -180,7 +166,7 @@ class ErrorHandler(Cog): log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") @staticmethod - async def handle_unexpected_error(ctx: Context, e: CommandError) -> None: + async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: """Generic handler for errors without an explicit handler.""" await ctx.send( f"Sorry, an unexpected error occurred. Please let us know!\n\n" -- cgit v1.2.3 From d2f94f4c1280716e32e13611f6c778f9d9d4efd3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 13:15:55 -0800 Subject: Error handler: handle MissingRequiredArgument Send a message indicating which argument is missing. --- bot/cogs/error_handler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index c65ada344..ffb36d10a 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -113,7 +113,10 @@ class ErrorHandler(Cog): # TODO: use ctx.send_help() once PR #519 is merged. help_command = await self.get_help_command(ctx.command) - if isinstance(e, errors.BadArgument): + if isinstance(e, errors.MissingRequiredArgument): + await ctx.send(f"Missing required argument `{e.param.name}`.") + await ctx.invoke(*help_command) + elif isinstance(e, errors.BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) else: -- cgit v1.2.3 From 6fa0ba18a6b4daa265e6716f9d360117378c67ab Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 13:17:03 -0800 Subject: Error handler: handle TooManyArguments Send a message specifying the error reason. --- bot/cogs/error_handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index ffb36d10a..5cf95e71a 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -116,6 +116,9 @@ class ErrorHandler(Cog): if isinstance(e, errors.MissingRequiredArgument): await ctx.send(f"Missing required argument `{e.param.name}`.") await ctx.invoke(*help_command) + elif isinstance(e, errors.TooManyArguments): + await ctx.send(f"Too many arguments provided.") + await ctx.invoke(*help_command) elif isinstance(e, errors.BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) -- cgit v1.2.3 From 8cdbec386d50e5866dcfd7cc0aeee359bb182317 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 13:23:56 -0800 Subject: Error handler: handle BadUnionArgument Send a message specifying the parameter name, the converters used, and the last error message from the converters. --- bot/cogs/error_handler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 5cf95e71a..d67261fc6 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -122,6 +122,8 @@ class ErrorHandler(Cog): elif isinstance(e, errors.BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) + elif isinstance(e, errors.BadUnionArgument): + await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```") else: await ctx.send("Something about your input seems off. Check the arguments:") await ctx.invoke(*help_command) -- cgit v1.2.3 From 4116aca0218138dd1a97db39b942a886945fa05b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 17:23:43 -0800 Subject: Error handler: handle ArgumentParsingError Simply send the error message with the help command. --- bot/cogs/error_handler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index d67261fc6..07b93283d 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -124,6 +124,8 @@ class ErrorHandler(Cog): await ctx.invoke(*help_command) elif isinstance(e, errors.BadUnionArgument): await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```") + elif isinstance(e, errors.ArgumentParsingError): + await ctx.send(f"Argument parsing error: {e}") else: await ctx.send("Something about your input seems off. Check the arguments:") await ctx.invoke(*help_command) -- cgit v1.2.3 From 476d5e7851f5b53ece319f023eeca88ae5c345eb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 17:38:41 -0800 Subject: Error handler: (almost) always log the error being handled The log level is debug for most errors and it's mainly useful for precisely that - debugging. This is why some "useless" errors are also logged e.g. CommandNotFound. Unexpected errors and some API errors will still have higher levels. * Add a single log statement to the end of the handler to cover UserInputError, CheckFailure, and CommandNotFound (when it's not trying to get a tag) * Log 404s from API --- bot/cogs/error_handler.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 07b93283d..ff8b36ddc 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -50,23 +50,26 @@ class ErrorHandler(Cog): if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): if ctx.channel.id != Channels.verification: await self.try_get_tag(ctx) + return # Exit early to avoid logging. elif isinstance(e, errors.UserInputError): await self.handle_user_input_error(ctx, e) elif isinstance(e, errors.CheckFailure): await self.handle_check_failure(ctx, e) - elif isinstance(e, (errors.CommandOnCooldown, errors.DisabledCommand)): - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) elif isinstance(e, errors.CommandInvokeError): if isinstance(e.original, ResponseCodeError): await self.handle_api_error(ctx, e.original) else: await self.handle_unexpected_error(ctx, e.original) - else: - # MaxConcurrencyReached, ExtensionError + return # Exit early to avoid logging. + elif not isinstance(e, (errors.CommandOnCooldown, errors.DisabledCommand)): + # ConversionError, 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}" + ) async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple: """Return the help command invocation args to display help for `command`.""" @@ -129,10 +132,6 @@ class ErrorHandler(Cog): else: await ctx.send("Something about your input seems off. Check the arguments:") await ctx.invoke(*help_command) - log.debug( - f"Command {ctx.command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: @@ -153,17 +152,13 @@ class ErrorHandler(Cog): ) elif isinstance(e, InChannelCheckFailure): await ctx.send(e) - else: - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) @staticmethod async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: """Handle ResponseCodeError exceptions.""" 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}") elif e.status == 400: content = await e.response.json() log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) -- cgit v1.2.3 From 9bfdf7e3e95c07a1b0369c9fa8bdb4c91339732f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 18:09:08 -0800 Subject: Error handler: simplify check failure handler & handle bot missing roles discord.py's default error messages are quite descriptive already so there really isn't a need to write our own. Therefore, the log calls were removed so that the generic debug log message is used in the on_command_error. In addition to handling missing bot permissions, missing bot roles are also handled. The message doesn't specify which because it doesn't really matter to the end-user. The logs will use the default error messages as described above, and those will contain the specific roles or permissions that are missing. --- bot/cogs/error_handler.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index ff8b36ddc..6c4074e3a 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -136,21 +136,17 @@ class ErrorHandler(Cog): @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: """Handle CheckFailure exceptions and its children.""" - command = ctx.command + bot_missing_errors = ( + errors.BotMissingPermissions, + errors.BotMissingRole, + errors.BotMissingAnyRole + ) - if isinstance(e, errors.NoPrivateMessage): - await ctx.send("Sorry, this command can't be used in a private message!") - elif isinstance(e, errors.BotMissingPermissions): - await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") - log.warning( - f"The bot is missing permissions to execute command {command}: {e.missing_perms}" - ) - elif isinstance(e, errors.MissingPermissions): - log.debug( - f"{ctx.message.author} is missing permissions to invoke command {command}: " - f"{e.missing_perms}" + if isinstance(e, bot_missing_errors): + await ctx.send( + f"Sorry, it looks like I don't have the permissions or roles I need to do that." ) - elif isinstance(e, InChannelCheckFailure): + elif isinstance(e, (InChannelCheckFailure, errors.NoPrivateMessage)): await ctx.send(e) @staticmethod -- cgit v1.2.3 From aa84854f942d68f5245d2ca99612dfdd6ad167ce Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 19:01:42 -0800 Subject: Error handler: update docstrings to reflect recent changes --- bot/cogs/error_handler.py | 59 +++++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 6c4074e3a..d2c806566 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -23,22 +23,22 @@ class ErrorHandler(Cog): """ Provide generic command error handling. - Error handling is deferred to any local error handler, if present. - - Error handling emits a single error response, prioritized as follows: - 1. If the name fails to match a command but matches a tag, the tag is invoked - 2. Send a BadArgument error message to the invoking context & invoke the command's help - 3. Send a UserInputError error message to the invoking context & invoke the command's help - 4. Send a NoPrivateMessage error message to the invoking context - 5. Send a BotMissingPermissions error message to the invoking context - 6. Log a MissingPermissions error, no message is sent - 7. Send a InChannelCheckFailure error message to the invoking context - 8. Log CheckFailure, CommandOnCooldown, and DisabledCommand errors, no message is sent - 9. For CommandInvokeErrors, response is based on the type of error: - * 404: Error message is sent to the invoking context - * 400: Log the resopnse JSON, no message is sent - * 500 <= status <= 600: Error message is sent to the invoking context - 10. Otherwise, handling is deferred to `handle_unexpected_error` + Error handling is deferred to any local error handler, if present. This is done by + checking for the presence of a `handled` attribute on the error. + + Error handling emits a single error message in the invoking context `ctx` and a log message, + prioritised as follows: + + 1. If the name fails to match a command but matches a tag, the tag is invoked + * If CommandNotFound is raised when invoking the tag (determined by the presence of the + `invoked_from_error_handler` attribute), this error is treated as being unexpected + and therefore sends an error message + * Commands in the verification channel are ignored + 2. UserInputError: see `handle_user_input_error` + 3. CheckFailure: see `handle_check_failure` + 4. ResponseCodeError: see `handle_api_error` + 5. Otherwise, if not a CommandOnCooldown or DisabledCommand, handling is deferred to + `handle_unexpected_error` """ command = ctx.command @@ -112,7 +112,16 @@ class ErrorHandler(Cog): return async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: - """Handle UserInputError exceptions and its children.""" + """ + Send an error message in `ctx` for UserInputError, sometimes invoking the help command too. + + * MissingRequiredArgument: send an error message with arg name and the help command + * TooManyArguments: send an error message and the help command + * BadArgument: send an error message and the help command + * BadUnionArgument: send an error message including the error produced by the last converter + * ArgumentParsingError: send an error message + * Other: send an error message and the help command + """ # TODO: use ctx.send_help() once PR #519 is merged. help_command = await self.get_help_command(ctx.command) @@ -135,7 +144,17 @@ class ErrorHandler(Cog): @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: - """Handle CheckFailure exceptions and its children.""" + """ + Send an error message in `ctx` for certain types of CheckFailure. + + The following types are handled: + + * BotMissingPermissions + * BotMissingRole + * BotMissingAnyRole + * NoPrivateMessage + * InChannelCheckFailure + """ bot_missing_errors = ( errors.BotMissingPermissions, errors.BotMissingRole, @@ -151,7 +170,7 @@ class ErrorHandler(Cog): @staticmethod async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: - """Handle ResponseCodeError exceptions.""" + """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}") @@ -168,7 +187,7 @@ class ErrorHandler(Cog): @staticmethod async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: - """Generic handler for errors without an explicit handler.""" + """Send a generic error message in `ctx` and log the exception as an error with exc_info.""" await ctx.send( f"Sorry, an unexpected error occurred. Please let us know!\n\n" f"```{e.__class__.__name__}: {e}```" -- cgit v1.2.3 From b3fdfa71ceeecbd9fe62cadc240fcad27bad7a32 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 19:06:22 -0800 Subject: Error handler: handle CommandOnCooldown errors Simply send the error's default message to the invoking context. --- bot/cogs/error_handler.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index d2c806566..347ce93ae 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -36,9 +36,9 @@ class ErrorHandler(Cog): * Commands in the verification channel are ignored 2. UserInputError: see `handle_user_input_error` 3. CheckFailure: see `handle_check_failure` - 4. ResponseCodeError: see `handle_api_error` - 5. Otherwise, if not a CommandOnCooldown or DisabledCommand, handling is deferred to - `handle_unexpected_error` + 4. CommandOnCooldown: send an error message in the invoking context + 5. ResponseCodeError: see `handle_api_error` + 6. Otherwise, if not a DisabledCommand, handling is deferred to `handle_unexpected_error` """ command = ctx.command @@ -55,13 +55,15 @@ class ErrorHandler(Cog): await self.handle_user_input_error(ctx, e) elif isinstance(e, errors.CheckFailure): await self.handle_check_failure(ctx, e) + elif isinstance(e, errors.CommandOnCooldown): + await ctx.send(e) elif isinstance(e, errors.CommandInvokeError): 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.CommandOnCooldown, errors.DisabledCommand)): + elif not isinstance(e, errors.DisabledCommand): # ConversionError, MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) return # Exit early to avoid logging. -- cgit v1.2.3 From 68cf5d8eb9721cb1d91ba004409b82b0a283782d Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 17:48:53 +0100 Subject: Use pregenerated partials This avoid recreating partials for each re-eval --- bot/cogs/snekbox.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 3fc8d9937..d075c4fd5 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -203,6 +203,9 @@ class Snekbox(Cog): log.info(f"Received code from {ctx.author} for evaluation:\n{code}") + _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) + _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) + while True: self.jobs[ctx.author.id] = datetime.datetime.now() code = self.prepare_input(code) @@ -234,13 +237,13 @@ class Snekbox(Cog): try: _, new_message = await self.bot.wait_for( 'message_edit', - check=partial(predicate_eval_message_edit, ctx), + check=_predicate_eval_message_edit, timeout=10 ) await ctx.message.add_reaction('๐Ÿ”') await self.bot.wait_for( 'reaction_add', - check=partial(predicate_eval_emoji_reaction, ctx), + check=_predicate_emoji_reaction, timeout=10 ) -- cgit v1.2.3 From e1e68ad561513cc02eac0eab59990430fbcfe516 Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 18:00:09 +0100 Subject: Suppress HTTPException while deleting bot output It was triggering an error if the user deleted the output before re-evaluating --- bot/cogs/snekbox.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index d075c4fd5..42830fb58 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -1,4 +1,5 @@ import asyncio +import contextlib import datetime import logging import re @@ -7,7 +8,7 @@ from functools import partial from signal import Signals from typing import Optional, Tuple -from discord import Message, Reaction, User +from discord import HTTPException, Message, Reaction, User from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot @@ -250,7 +251,8 @@ class Snekbox(Cog): log.info(f"Re-evaluating message {ctx.message.id}") code = new_message.content.split(' ', maxsplit=1)[1] await ctx.message.clear_reactions() - await response.delete() + with contextlib.suppress(HTTPException): + await response.delete() except asyncio.TimeoutError: await ctx.message.clear_reactions() return -- cgit v1.2.3 From 8b386b533662e7f4be44cc57b9d9c63fde8a7ebf Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 18:14:31 +0100 Subject: Snekbox small refactoring Makes the code a bit clearer Co-authored-by: Shirayuki Nekomata --- bot/cogs/snekbox.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 42830fb58..efa4696b5 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -158,8 +158,8 @@ class Snekbox(Cog): lines = output.count("\n") if lines > 0: - output = output.split("\n")[:11] # Only first 11 cause the rest is truncated anyway - output = (f"{i:03d} | {line}" for i, line in enumerate(output, 1)) + output = [f"{i:03d} | {line}" for i, line in enumerate(output.split('\n'), 1)] + output = output[:11] # Limiting to only 11 lines output = "\n".join(output) if lines > 10: @@ -175,8 +175,7 @@ class Snekbox(Cog): if truncated: paste_link = await self.upload_output(original_output) - if not output: - output = "[No output]" + output = output or "[No output]" return output, paste_link -- cgit v1.2.3 From 33769405549efc3c0571cd1b2da5fa0b59ec742a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:23:38 +0100 Subject: Split assertion onto separate lines Co-Authored-By: Mark --- tests/bot/cogs/test_snekbox.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 293efed0f..b5190cd7f 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -35,7 +35,8 @@ class SnekboxTests(unittest.TestCase): @async_test async def test_upload_output_reject_too_long(self): """Reject output longer than MAX_PASTE_LEN.""" - self.assertEqual(await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)), "too long to upload") + result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) + self.assertEqual(result, "too long to upload") @async_test async def test_upload_output(self): -- cgit v1.2.3 From 2974d489494370f364c44899a529f5e93c89bedc Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:25:55 +0100 Subject: Split assertions onto separate lines Reads better as separate lines Co-Authored-By: Mark --- tests/bot/cogs/test_snekbox.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index b5190cd7f..01525110d 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -88,7 +88,8 @@ class SnekboxTests(unittest.TestCase): ) for stdout, returncode, expected in cases: with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - self.assertEqual(self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}), expected) + actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) @patch('bot.cogs.snekbox.Signals', side_effect=ValueError) def test_get_results_message_invalid_signal(self, mock_Signals: Mock): @@ -114,7 +115,8 @@ class SnekboxTests(unittest.TestCase): ) for stdout, returncode, expected in cases: with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - self.assertEqual(self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}), expected) + actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) @async_test async def test_format_output(self): @@ -321,7 +323,8 @@ class SnekboxTests(unittest.TestCase): with self.subTest(msg=f'Messages with {testname} return {expected}'): ctx = MockContext() ctx.message = ctx_msg - self.assertEqual(snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg), expected) + actual = snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg) + self.assertEqual(actual, expected) def test_predicate_eval_emoji_reaction(self): """Test the predicate_eval_emoji_reaction function.""" @@ -351,7 +354,8 @@ class SnekboxTests(unittest.TestCase): ) for reaction, user, expected, testname in cases: with self.subTest(msg=f'Test with {testname} and expected return {expected}'): - self.assertEqual(snekbox.predicate_eval_emoji_reaction(valid_ctx, reaction, user), expected) + actual = snekbox.predicate_eval_emoji_reaction(valid_ctx, reaction, user) + self.assertEqual(actual, expected) class SnekboxSetupTests(unittest.TestCase): -- cgit v1.2.3 From dd9c250253a7bb24751f2de274dfa168efafb717 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:29:22 +0100 Subject: Delete additional informations from subtest Reduce visual clutter Co-Authored-By: Mark --- tests/bot/cogs/test_snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 01525110d..f1f03ab2f 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -76,7 +76,7 @@ class SnekboxTests(unittest.TestCase): ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), ) for case, expected, testname in cases: - with self.subTest(msg=f'Extract code from {testname}.', case=case, expected=expected): + with self.subTest(msg=f'Extract code from {testname}.'): self.assertEqual(self.cog.prepare_input(case), expected) def test_get_results_message(self): -- cgit v1.2.3 From b5a1bf7c6ca467cef40a429cc8ca02314526801c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:30:39 +0100 Subject: Use a space instead of an empty string in test_get_status_emoji Because of the stripping, it should still be considered as empty Co-Authored-By: Mark --- tests/bot/cogs/test_snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index f1f03ab2f..94ff685c4 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -109,7 +109,7 @@ class SnekboxTests(unittest.TestCase): def test_get_status_emoji(self): """Return emoji according to the eval result.""" cases = ( - ('', -1, ':warning:'), + (' ', -1, ':warning:'), ('Hello world!', 0, ':white_check_mark:'), ('Invalid beard size', -1, ':x:') ) -- cgit v1.2.3 From c1a998ca246e153333438ff91bce200bf74cc0f5 Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 18:38:24 +0100 Subject: Assert return value of Snekbox.post_eval --- tests/bot/cogs/test_snekbox.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 94ff685c4..cf07aad71 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -25,7 +25,9 @@ class SnekboxTests(unittest.TestCase): @async_test async def test_post_eval(self): """Post the eval code to the URLs.snekbox_eval_api endpoint.""" - await self.cog.post_eval("import random") + self.mocked_post.json.return_value = {'lemon': 'AI'} + + self.assertEqual(await self.cog.post_eval("import random"), {'lemon': 'AI'}) self.bot.http_session.post.assert_called_once_with( URLs.snekbox_eval_api, json={"input": "import random"}, -- cgit v1.2.3 From 12f43fc09406dee8cb1b36757fc2af7ae799a9d5 Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 18:44:00 +0100 Subject: Use kwargs to set mock attributes --- tests/bot/cogs/test_snekbox.py | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index cf07aad71..112c923c8 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -306,15 +306,9 @@ class SnekboxTests(unittest.TestCase): def test_predicate_eval_message_edit(self): """Test the predicate_eval_message_edit function.""" - msg0 = MockMessage() - msg0.id = 1 - msg0.content = 'abc' - msg1 = MockMessage() - msg1.id = 2 - msg1.content = 'abcdef' - msg2 = MockMessage() - msg2.id = 1 - msg2.content = 'abcdef' + msg0 = MockMessage(id=1, content='abc') + msg1 = MockMessage(id=2, content='abcdef') + msg2 = MockMessage(id=1, content='abcdef') cases = ( (msg0, msg0, False, 'same ID, same content'), @@ -323,29 +317,21 @@ class SnekboxTests(unittest.TestCase): ) for ctx_msg, new_msg, expected, testname in cases: with self.subTest(msg=f'Messages with {testname} return {expected}'): - ctx = MockContext() - ctx.message = ctx_msg + ctx = MockContext(message=ctx_msg) actual = snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg) self.assertEqual(actual, expected) def test_predicate_eval_emoji_reaction(self): """Test the predicate_eval_emoji_reaction function.""" - valid_reaction = MockReaction() - valid_reaction.message.id = 1 + valid_reaction = MockReaction(message=MockMessage(id=1)) valid_reaction.__str__.return_value = '๐Ÿ”' - valid_ctx = MockContext() - valid_ctx.message.id = 1 - valid_ctx.author.id = 2 - valid_user = MockUser() - valid_user.id = 2 - - invalid_reaction_id = MockReaction() - invalid_reaction_id.message.id = 42 + valid_ctx = MockContext(message=MockMessage(id=1), author=MockUser(id=2)) + valid_user = MockUser(id=2) + + invalid_reaction_id = MockReaction(message=MockMessage(id=42)) invalid_reaction_id.__str__.return_value = '๐Ÿ”' - invalid_user_id = MockUser() - invalid_user_id.id = 42 - invalid_reaction_str = MockReaction() - invalid_reaction_str.message.id = 1 + invalid_user_id = MockUser(id=42) + invalid_reaction_str = MockReaction(message=MockMessage(id=1)) invalid_reaction_str.__str__.return_value = ':longbeard:' cases = ( -- cgit v1.2.3 From 05de1f705a9dda3e6b15f0772ddbdc6876ffeb8d Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 12:31:01 +0100 Subject: Update to Python 3.8 and discord.py 1.3.2 I've changed the Python version in our Pipfile to Python 3.8. The main advantage of Python 3.8 is that it comes with significant upgrades to the unittest module which allow us to test asyncio-based code more easily. While our current test suite runs in P3.8 "out of the box", it currently still relies on many of the workarounds we had to use to test asynchronous code in Python 3.7. A future commit will replace these workarounds with the new tools available in Python 3.8. This commit also updates our discord.py version to 1.3.2. Versions of discord.py <= 1.3.1 contain a bug that causes errors in the new unittest tools that come with Python 3.8. For more specific details, see https://github.com/Rapptz/discord.py/pull/2570. --- Pipfile | 4 +-- Pipfile.lock | 112 +++++++++++++++++++---------------------------------------- 2 files changed, 37 insertions(+), 79 deletions(-) diff --git a/Pipfile b/Pipfile index 400e64c18..e08b5b41d 100644 --- a/Pipfile +++ b/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -discord-py = "~=1.3.1" +discord-py = "~=1.3.2" aiodns = "~=2.0" aiohttp = "~=3.5" sphinx = "~=2.2" @@ -36,7 +36,7 @@ unittest-xml-reporting = "~=2.5" dodgy = "~=0.1" [requires] -python_version = "3.7" +python_version = "3.8" [scripts] start = "python -m bot" diff --git a/Pipfile.lock b/Pipfile.lock index fa29bf995..7c11f1860 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "c7706a61eb96c06d073898018ea2dbcf5bd3b15d007496e2d60120a65647f31e" + "sha256": "513182efe8c06f5d8acb494ebdfb8670cd68f426fd87085778421872c2c3acc8" }, "pipfile-spec": 6, "requires": { - "python_version": "3.7" + "python_version": "3.8" }, "sources": [ { @@ -150,10 +150,10 @@ }, "discord-py": { "hashes": [ - "sha256:8bfe5628d31771744000f19135c386c74ac337479d7282c26cc1627b9d31f360" + "sha256:7424be26b07b37ecad4404d9383d685995a0e0b3df3f9c645bdd3a4d977b83b4" ], "index": "pypi", - "version": "==1.3.1" + "version": "==1.3.2" }, "docutils": { "hashes": [ @@ -279,25 +279,25 @@ }, "multidict": { "hashes": [ - "sha256:13f3ebdb5693944f52faa7b2065b751cb7e578b8dd0a5bb8e4ab05ad0188b85e", - "sha256:26502cefa86d79b86752e96639352c7247846515c864d7c2eb85d036752b643c", - "sha256:4fba5204d32d5c52439f88437d33ad14b5f228e25072a192453f658bddfe45a7", - "sha256:527124ef435f39a37b279653ad0238ff606b58328ca7989a6df372fd75d7fe26", - "sha256:5414f388ffd78c57e77bd253cf829373721f450613de53dc85a08e34d806e8eb", - "sha256:5eee66f882ab35674944dfa0d28b57fa51e160b4dce0ce19e47f495fdae70703", - "sha256:63810343ea07f5cd86ba66ab66706243a6f5af075eea50c01e39b4ad6bc3c57a", - "sha256:6bd10adf9f0d6a98ccc792ab6f83d18674775986ba9bacd376b643fe35633357", - "sha256:83c6ddf0add57c6b8a7de0bc7e2d656be3eefeff7c922af9a9aae7e49f225625", - "sha256:93166e0f5379cf6cd29746989f8a594fa7204dcae2e9335ddba39c870a287e1c", - "sha256:9a7b115ee0b9b92d10ebc246811d8f55d0c57e82dbb6a26b23c9a9a6ad40ce0c", - "sha256:a38baa3046cce174a07a59952c9f876ae8875ef3559709639c17fdf21f7b30dd", - "sha256:a6d219f49821f4b2c85c6d426346a5d84dab6daa6f85ca3da6c00ed05b54022d", - "sha256:a8ed33e8f9b67e3b592c56567135bb42e7e0e97417a4b6a771e60898dfd5182b", - "sha256:d7d428488c67b09b26928950a395e41cc72bb9c3d5abfe9f0521940ee4f796d4", - "sha256:dcfed56aa085b89d644af17442cdc2debaa73388feba4b8026446d168ca8dad7", - "sha256:f29b885e4903bd57a7789f09fe9d60b6475a6c1a4c0eca874d8558f00f9d4b51" - ], - "version": "==4.7.4" + "sha256:317f96bc0950d249e96d8d29ab556d01dd38888fbe68324f46fd834b430169f1", + "sha256:42f56542166040b4474c0c608ed051732033cd821126493cf25b6c276df7dd35", + "sha256:4b7df040fb5fe826d689204f9b544af469593fb3ff3a069a6ad3409f742f5928", + "sha256:544fae9261232a97102e27a926019100a9db75bec7b37feedd74b3aa82f29969", + "sha256:620b37c3fea181dab09267cd5a84b0f23fa043beb8bc50d8474dd9694de1fa6e", + "sha256:6e6fef114741c4d7ca46da8449038ec8b1e880bbe68674c01ceeb1ac8a648e78", + "sha256:7774e9f6c9af3f12f296131453f7b81dabb7ebdb948483362f5afcaac8a826f1", + "sha256:85cb26c38c96f76b7ff38b86c9d560dea10cf3459bb5f4caf72fc1bb932c7136", + "sha256:a326f4240123a2ac66bb163eeba99578e9d63a8654a59f4688a79198f9aa10f8", + "sha256:ae402f43604e3b2bc41e8ea8b8526c7fa7139ed76b0d64fc48e28125925275b2", + "sha256:aee283c49601fa4c13adc64c09c978838a7e812f85377ae130a24d7198c0331e", + "sha256:b51249fdd2923739cd3efc95a3d6c363b67bbf779208e9f37fd5e68540d1a4d4", + "sha256:bb519becc46275c594410c6c28a8a0adc66fe24fef154a9addea54c1adb006f5", + "sha256:c2c37185fb0af79d5c117b8d2764f4321eeb12ba8c141a95d0aa8c2c1d0a11dd", + "sha256:dc561313279f9d05a3d0ffa89cd15ae477528ea37aa9795c4654588a3287a9ab", + "sha256:e439c9a10a95cb32abd708bb8be83b2134fa93790a4fb0535ca36db3dda94d20", + "sha256:fc3b4adc2ee8474cb3cd2a155305d5f8eda0a9c91320f83e55748e1fcb68f8e3" + ], + "version": "==4.7.5" }, "ordered-set": { "hashes": [ @@ -437,18 +437,18 @@ }, "soupsieve": { "hashes": [ - "sha256:bdb0d917b03a1369ce964056fc195cfdff8819c40de04695a80bc813c3cfa1f5", - "sha256:e2c1c5dee4a1c36bcb790e0fabd5492d874b8ebd4617622c4f6a731701060dda" + "sha256:e914534802d7ffd233242b785229d5ba0766a7f487385e3f714446a07bf540ae", + "sha256:fcd71e08c0aee99aca1b73f45478549ee7e7fc006d51b37bec9e9def7dc22b69" ], - "version": "==1.9.5" + "version": "==2.0" }, "sphinx": { "hashes": [ - "sha256:525527074f2e0c2585f68f73c99b4dc257c34bbe308b27f5f8c7a6e20642742f", - "sha256:543d39db5f82d83a5c1aa0c10c88f2b6cff2da3e711aa849b2c627b4b403bbd9" + "sha256:776ff8333181138fae52df65be733127539623bb46cc692e7fa0fcfc80d7aa88", + "sha256:ca762da97c3b5107cbf0ab9e11d3ec7ab8d3c31377266fd613b962ed971df709" ], "index": "pypi", - "version": "==2.4.2" + "version": "==2.4.3" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -466,10 +466,10 @@ }, "sphinxcontrib-htmlhelp": { "hashes": [ - "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", - "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" + "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", + "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], - "version": "==1.0.2" + "version": "==1.0.3" }, "sphinxcontrib-jsmath": { "hashes": [ @@ -750,14 +750,6 @@ ], "version": "==2.9" }, - "importlib-metadata": { - "hashes": [ - "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", - "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" - ], - "markers": "python_version < '3.8'", - "version": "==1.5.0" - }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", @@ -868,33 +860,6 @@ ], "version": "==0.10.0" }, - "typed-ast": { - "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" - ], - "markers": "python_version < '3.8'", - "version": "==1.4.1" - }, "unittest-xml-reporting": { "hashes": [ "sha256:358bbdaf24a26d904cc1c26ef3078bca7fc81541e0a54c8961693cc96a6f35e0", @@ -913,17 +878,10 @@ }, "virtualenv": { "hashes": [ - "sha256:08f3623597ce73b85d6854fb26608a6f39ee9d055c81178dc6583803797f8994", - "sha256:de2cbdd5926c48d7b84e0300dea9e8f276f61d186e8e49223d71d91250fbaebd" + "sha256:531b142e300d405bb9faedad4adbeb82b4098b918e35209af2adef3129274aae", + "sha256:5dd42a9f56307542bddc446cfd10ef6576f11910366a07609fe8d0d88fa8fb7e" ], - "version": "==20.0.4" - }, - "zipp": { - "hashes": [ - "sha256:12248a63bbdf7548f89cb4c7cda4681e537031eda29c02ea29674bc6854460c2", - "sha256:7c0f8e91abc0dc07a5068f315c52cb30c66bfbc581e5b50704c8a2f6ebae794a" - ], - "version": "==3.0.0" + "version": "==20.0.5" } } } -- cgit v1.2.3 From d3f4673c1a1c3f5213840e756c5f35f7c70d46f6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 12:39:25 +0100 Subject: Use mixin-composition not inheritance for LoggingTestCase We used inheritence to add additional logging assertion methods to unittest's TestCase class. However, with the introduction of the new IsolatedAsyncioTestCase this extension strategy means we'd have to create multiple child classes to be able to use the extended functionality in all of the TestCase variants. Since that leads to undesirable code reuse and an inheritance relationship is not at all needed, I've switched to a mixin-composition based approach that allows the user to extend the functionality of any TestCase variant with a mixin where needed. --- tests/base.py | 10 +++++++--- tests/bot/cogs/test_duck_pond.py | 2 +- tests/test_base.py | 18 ++++++------------ 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/tests/base.py b/tests/base.py index 029a249ed..21a57716a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,5 +1,4 @@ import logging -import unittest from contextlib import contextmanager @@ -16,8 +15,13 @@ class _CaptureLogHandler(logging.Handler): self.records.append(record) -class LoggingTestCase(unittest.TestCase): - """TestCase subclass that adds more logging assertion tools.""" +class LoggingTestsMixin: + """ + A mixin that defines additional test methods for logging behavior. + + This mixin relies on the availability of the `fail` attribute defined by the + test classes included in Python's unittest method to signal test failure. + """ @contextmanager def assertNotLogs(self, logger=None, level=None, msg=None): diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index d07b2bce1..320cbd5c5 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -14,7 +14,7 @@ from tests import helpers MODULE_PATH = "bot.cogs.duck_pond" -class DuckPondTests(base.LoggingTestCase): +class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): """Tests for DuckPond functionality.""" @classmethod diff --git a/tests/test_base.py b/tests/test_base.py index a16e2af8f..23abb1dfd 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -3,7 +3,11 @@ import unittest import unittest.mock -from tests.base import LoggingTestCase, _CaptureLogHandler +from tests.base import LoggingTestsMixin, _CaptureLogHandler + + +class LoggingTestCase(LoggingTestsMixin): + pass class LoggingTestCaseTests(unittest.TestCase): @@ -18,19 +22,9 @@ class LoggingTestCaseTests(unittest.TestCase): try: with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): pass - except AssertionError: + except AssertionError: # pragma: no cover self.fail("`self.assertNotLogs` raised an AssertionError when it should not!") - @unittest.mock.patch("tests.base.LoggingTestCase.assertNotLogs") - def test_the_test_function_assert_not_logs_does_not_raise_with_no_logs(self, assertNotLogs): - """Test if test_assert_not_logs_does_not_raise_with_no_logs captures exception correctly.""" - assertNotLogs.return_value = iter([None]) - assertNotLogs.side_effect = AssertionError - - message = "`self.assertNotLogs` raised an AssertionError when it should not!" - with self.assertRaises(AssertionError, msg=message): - self.test_assert_not_logs_does_not_raise_with_no_logs() - def test_assert_not_logs_raises_correct_assertion_error_when_logs_are_emitted(self): """Test if LoggingTestCase.assertNotLogs raises AssertionError when logs were emitted.""" msg_regex = ( -- cgit v1.2.3 From 135d6daa4804574935cd788c5baec656765f484b Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 13:05:10 +0100 Subject: Use IsolatedAsyncioTestCase instead of async_test Since we upgraded to Python 3.8, we can now use the new IsolatedAsyncioTestCase test class to use coroutine-based test methods instead of our own, custom async_test decorator. I have changed the base class for all of our test classes that use coroutine-based test methods and removed the now obsolete decorator from our helpers. --- tests/bot/cogs/test_duck_pond.py | 11 +---------- tests/bot/rules/__init__.py | 2 +- tests/bot/rules/test_attachments.py | 4 +--- tests/bot/rules/test_burst.py | 4 +--- tests/bot/rules/test_burst_shared.py | 4 +--- tests/bot/rules/test_chars.py | 4 +--- tests/bot/rules/test_discord_emojis.py | 4 +--- tests/bot/rules/test_duplicates.py | 4 +--- tests/bot/rules/test_links.py | 4 +--- tests/bot/rules/test_mentions.py | 4 +--- tests/bot/rules/test_newlines.py | 5 +---- tests/bot/rules/test_role_mentions.py | 4 +--- tests/bot/test_api.py | 4 +--- tests/helpers.py | 17 ----------------- tests/test_helpers.py | 8 -------- 15 files changed, 13 insertions(+), 70 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 320cbd5c5..6406f0737 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -14,7 +14,7 @@ from tests import helpers MODULE_PATH = "bot.cogs.duck_pond" -class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): +class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): """Tests for DuckPond functionality.""" @classmethod @@ -88,7 +88,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): self.assertEqual(expected_return, actual_return) - @helpers.async_test async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): """The `has_green_checkmark` method should only return `True` if one is present.""" test_cases = ( @@ -172,7 +171,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) - @helpers.async_test async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): """The `count_ducks` method should return the number of unique staffers who gave a duck.""" test_cases = ( @@ -280,7 +278,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): self.assertEqual(expected_count, actual_count) - @helpers.async_test async def test_relay_message_correctly_relays_content_and_attachments(self): """The `relay_message` method should correctly relay message content and attachments.""" send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" @@ -307,7 +304,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): message.add_reaction.assert_called_once_with(self.checkmark_emoji) @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) - @helpers.async_test async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): """The `relay_message` method should handle irretrievable attachments.""" message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) @@ -327,7 +323,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) - @helpers.async_test async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): """The `relay_message` method should handle irretrievable attachments.""" message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) @@ -360,7 +355,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): payload.emoji.name = emoji_name return payload - @helpers.async_test async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" test_values = ( @@ -434,7 +428,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): return channel, message, member, payload - @helpers.async_test async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" channel_id = 1234 @@ -485,7 +478,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): # Assert that we've made it past `self.is_staff` is_staff.assert_called_once() - @helpers.async_test async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" test_cases = ( @@ -515,7 +507,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): if should_relay: relay_message.assert_called_once_with(message) - @helpers.async_test async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) diff --git a/tests/bot/rules/__init__.py b/tests/bot/rules/__init__.py index 36c986fe1..0233e7939 100644 --- a/tests/bot/rules/__init__.py +++ b/tests/bot/rules/__init__.py @@ -12,7 +12,7 @@ class DisallowedCase(NamedTuple): n_violations: int -class RuleTest(unittest.TestCase, metaclass=ABCMeta): +class RuleTest(unittest.IsolatedAsyncioTestCase, metaclass=ABCMeta): """ Abstract class for antispam rule test cases. diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index e54b4b5b8..d7e779221 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import attachments from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, total_attachments: int) -> MockMessage: @@ -17,7 +17,6 @@ class AttachmentRuleTests(RuleTest): self.apply = attachments.apply self.config = {"max": 5, "interval": 10} - @async_test async def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" cases = ( @@ -28,7 +27,6 @@ class AttachmentRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_with_too_many_attachments(self): """Messages with too many attachments trigger the rule.""" cases = ( diff --git a/tests/bot/rules/test_burst.py b/tests/bot/rules/test_burst.py index 72f0be0c7..03682966b 100644 --- a/tests/bot/rules/test_burst.py +++ b/tests/bot/rules/test_burst.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import burst from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str) -> MockMessage: @@ -21,7 +21,6 @@ class BurstRuleTests(RuleTest): self.apply = burst.apply self.config = {"max": 2, "interval": 10} - @async_test async def test_allows_messages_within_limit(self): """Cases which do not violate the rule.""" cases = ( @@ -31,7 +30,6 @@ class BurstRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases where the amount of messages exceeds the limit, triggering the rule.""" cases = ( diff --git a/tests/bot/rules/test_burst_shared.py b/tests/bot/rules/test_burst_shared.py index 47367a5f8..3275143d5 100644 --- a/tests/bot/rules/test_burst_shared.py +++ b/tests/bot/rules/test_burst_shared.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import burst_shared from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str) -> MockMessage: @@ -21,7 +21,6 @@ class BurstSharedRuleTests(RuleTest): self.apply = burst_shared.apply self.config = {"max": 2, "interval": 10} - @async_test async def test_allows_messages_within_limit(self): """ Cases that do not violate the rule. @@ -34,7 +33,6 @@ class BurstSharedRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases where the amount of messages exceeds the limit, triggering the rule.""" cases = ( diff --git a/tests/bot/rules/test_chars.py b/tests/bot/rules/test_chars.py index 7cc36f49e..f1e3c76a7 100644 --- a/tests/bot/rules/test_chars.py +++ b/tests/bot/rules/test_chars.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import chars from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, n_chars: int) -> MockMessage: @@ -20,7 +20,6 @@ class CharsRuleTests(RuleTest): "interval": 10, } - @async_test async def test_allows_messages_within_limit(self): """Cases with a total amount of chars within limit.""" cases = ( @@ -31,7 +30,6 @@ class CharsRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases where the total amount of chars exceeds the limit, triggering the rule.""" cases = ( diff --git a/tests/bot/rules/test_discord_emojis.py b/tests/bot/rules/test_discord_emojis.py index 0239b0b00..9a72723e2 100644 --- a/tests/bot/rules/test_discord_emojis.py +++ b/tests/bot/rules/test_discord_emojis.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import discord_emojis from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage discord_emoji = "<:abcd:1234>" # Discord emojis follow the format <:name:id> @@ -19,7 +19,6 @@ class DiscordEmojisRuleTests(RuleTest): self.apply = discord_emojis.apply self.config = {"max": 2, "interval": 10} - @async_test async def test_allows_messages_within_limit(self): """Cases with a total amount of discord emojis within limit.""" cases = ( @@ -29,7 +28,6 @@ class DiscordEmojisRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases with more than the allowed amount of discord emojis.""" cases = ( diff --git a/tests/bot/rules/test_duplicates.py b/tests/bot/rules/test_duplicates.py index 59e0fb6ef..9bd886a77 100644 --- a/tests/bot/rules/test_duplicates.py +++ b/tests/bot/rules/test_duplicates.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import duplicates from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, content: str) -> MockMessage: @@ -17,7 +17,6 @@ class DuplicatesRuleTests(RuleTest): self.apply = duplicates.apply self.config = {"max": 2, "interval": 10} - @async_test async def test_allows_messages_within_limit(self): """Cases which do not violate the rule.""" cases = ( @@ -28,7 +27,6 @@ class DuplicatesRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases with too many duplicate messages from the same author.""" cases = ( diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index 3c3f90e5f..b091bd9d7 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import links from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, total_links: int) -> MockMessage: @@ -21,7 +21,6 @@ class LinksTests(RuleTest): "interval": 10 } - @async_test async def test_links_within_limit(self): """Messages with an allowed amount of links.""" cases = ( @@ -34,7 +33,6 @@ class LinksTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_links_exceeding_limit(self): """Messages with a a higher than allowed amount of links.""" cases = ( diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index ebcdabac6..6444532f2 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import mentions from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, total_mentions: int) -> MockMessage: @@ -20,7 +20,6 @@ class TestMentions(RuleTest): "interval": 10, } - @async_test async def test_mentions_within_limit(self): """Messages with an allowed amount of mentions.""" cases = ( @@ -32,7 +31,6 @@ class TestMentions(RuleTest): await self.run_allowed(cases) - @async_test async def test_mentions_exceeding_limit(self): """Messages with a higher than allowed amount of mentions.""" cases = ( diff --git a/tests/bot/rules/test_newlines.py b/tests/bot/rules/test_newlines.py index d61c4609d..e35377773 100644 --- a/tests/bot/rules/test_newlines.py +++ b/tests/bot/rules/test_newlines.py @@ -2,7 +2,7 @@ from typing import Iterable, List from bot.rules import newlines from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, newline_groups: List[int]) -> MockMessage: @@ -29,7 +29,6 @@ class TotalNewlinesRuleTests(RuleTest): "interval": 10, } - @async_test async def test_allows_messages_within_limit(self): """Cases which do not violate the rule.""" cases = ( @@ -41,7 +40,6 @@ class TotalNewlinesRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_total(self): """Cases which violate the rule by having too many newlines in total.""" cases = ( @@ -79,7 +77,6 @@ class GroupNewlinesRuleTests(RuleTest): self.apply = newlines.apply self.config = {"max": 5, "max_consecutive": 3, "interval": 10} - @async_test async def test_disallows_messages_consecutive(self): """Cases which violate the rule due to having too many consecutive newlines.""" cases = ( diff --git a/tests/bot/rules/test_role_mentions.py b/tests/bot/rules/test_role_mentions.py index b339cccf7..26c05d527 100644 --- a/tests/bot/rules/test_role_mentions.py +++ b/tests/bot/rules/test_role_mentions.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import role_mentions from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, n_mentions: int) -> MockMessage: @@ -17,7 +17,6 @@ class RoleMentionsRuleTests(RuleTest): self.apply = role_mentions.apply self.config = {"max": 2, "interval": 10} - @async_test async def test_allows_messages_within_limit(self): """Cases with a total amount of role mentions within limit.""" cases = ( @@ -27,7 +26,6 @@ class RoleMentionsRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases with more than the allowed amount of role mentions.""" cases = ( diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py index bdfcc73e4..99e942813 100644 --- a/tests/bot/test_api.py +++ b/tests/bot/test_api.py @@ -2,10 +2,9 @@ import unittest from unittest.mock import MagicMock from bot import api -from tests.helpers import async_test -class APIClientTests(unittest.TestCase): +class APIClientTests(unittest.IsolatedAsyncioTestCase): """Tests for the bot's API client.""" @classmethod @@ -18,7 +17,6 @@ class APIClientTests(unittest.TestCase): """The event loop should not be running by default.""" self.assertFalse(api.loop_is_running()) - @async_test async def test_loop_is_running_in_async_context(self): """The event loop should be running in an async context.""" self.assertTrue(api.loop_is_running()) diff --git a/tests/helpers.py b/tests/helpers.py index 5df796c23..01752a791 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,8 +1,6 @@ from __future__ import annotations -import asyncio import collections -import functools import inspect import itertools import logging @@ -25,21 +23,6 @@ for logger in logging.Logger.manager.loggerDict.values(): logger.setLevel(logging.CRITICAL) -def async_test(wrapped): - """ - Run a test case via asyncio. - Example: - >>> @async_test - ... async def lemon_wins(): - ... assert True - """ - - @functools.wraps(wrapped) - def wrapper(*args, **kwargs): - return asyncio.run(wrapped(*args, **kwargs)) - return wrapper - - class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 7894e104a..fe39df308 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -395,11 +395,3 @@ class MockObjectTests(unittest.TestCase): coroutine = async_mock() self.assertTrue(inspect.iscoroutine(coroutine)) self.assertIsNotNone(asyncio.run(coroutine)) - - def test_async_test_decorator_allows_synchronous_call_to_async_def(self): - """Test if the `async_test` decorator allows an `async def` to be called synchronously.""" - @helpers.async_test - async def kosayoda(): - return "return value" - - self.assertEqual(kosayoda(), "return value") -- cgit v1.2.3 From b6500eb967ae4856d4d65d7946b1e341c093eedd Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 13:41:27 +0100 Subject: Remove lingering pytest test_time.py file I forgot to remove one pytest test file during the migration from pytest to unittest. Since we have sinced added a unittest version of the same file, I've now removed the lingering pytest file. --- tests/utils/test_time.py | 62 ------------------------------------------------ 1 file changed, 62 deletions(-) delete mode 100644 tests/utils/test_time.py diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py deleted file mode 100644 index 4baa6395c..000000000 --- a/tests/utils/test_time.py +++ /dev/null @@ -1,62 +0,0 @@ -import asyncio -from datetime import datetime, timezone -from unittest.mock import patch - -import pytest -from dateutil.relativedelta import relativedelta - -from bot.utils import time -from tests.helpers import AsyncMock - - -@pytest.mark.parametrize( - ('delta', 'precision', 'max_units', 'expected'), - ( - (relativedelta(days=2), 'seconds', 1, '2 days'), - (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), - (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), - (relativedelta(days=2, hours=2), 'days', 2, '2 days'), - - # Does not abort for unknown units, as the unit name is checked - # against the attribute of the relativedelta instance. - (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), - - # Very high maximum units, but it only ever iterates over - # each value the relativedelta might have. - (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), - ) -) -def test_humanize_delta( - delta: relativedelta, - precision: str, - max_units: int, - expected: str -): - assert time.humanize_delta(delta, precision, max_units) == expected - - -@pytest.mark.parametrize('max_units', (-1, 0)) -def test_humanize_delta_raises_for_invalid_max_units(max_units: int): - with pytest.raises(ValueError, match='max_units must be positive'): - time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) - - -@pytest.mark.parametrize( - ('stamp', 'expected'), - ( - ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), - ) -) -def test_parse_rfc1123(stamp: str, expected: str): - assert time.parse_rfc1123(stamp) == expected - - -@patch('asyncio.sleep', new_callable=AsyncMock) -def test_wait_until(sleep_patch): - start = datetime(2019, 1, 1, 0, 0) - then = datetime(2019, 1, 1, 0, 10) - - # No return value - assert asyncio.run(time.wait_until(then, start)) is None - - sleep_patch.assert_called_once_with(10 * 60) -- cgit v1.2.3 From ea64d7cc6defa759fc1c7f1631a7ae9b8073cc29 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 20:53:45 +0100 Subject: Use unittest's AsyncMock instead of our AsyncMock Python 3.8 introduced an `unittest.mock.AsyncMock` class that can be used to mock coroutines and other types of asynchronous operations like async iterators and async context managers. As we were using our custom, but limited, AsyncMock, I have replaced our mock with unittest's AsyncMock. Since Python 3.8 also introduces a different way of automatically detecting which attributes should be mocked with an AsyncMock, I've changed our CustomMockMixin to use this new method as well. Together with a couple other small changes, this means that our Custom Mocks now use a lazy method of detecting coroutine attributes, which significantly speeds up the test suite. --- tests/bot/cogs/test_duck_pond.py | 22 ++-- tests/bot/cogs/test_information.py | 34 +++---- tests/bot/cogs/test_token_remover.py | 4 +- tests/bot/utils/test_time.py | 3 +- tests/helpers.py | 190 ++++++++++++----------------------- tests/test_helpers.py | 63 ++---------- 6 files changed, 103 insertions(+), 213 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 6406f0737..e164f7544 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -2,7 +2,7 @@ import asyncio import logging import typing import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import discord @@ -293,8 +293,8 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): ) for message, expect_webhook_call, expect_attachment_call in test_values: - with patch(send_webhook_path, new_callable=helpers.AsyncMock) as send_webhook: - with patch(send_attachments_path, new_callable=helpers.AsyncMock) as send_attachments: + with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook: + with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments: with self.subTest(clean_content=message.clean_content, attachments=message.attachments): await self.cog.relay_message(message) @@ -303,7 +303,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): message.add_reaction.assert_called_once_with(self.checkmark_emoji) - @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): """The `relay_message` method should handle irretrievable attachments.""" message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) @@ -314,15 +314,15 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): for side_effect in side_effects: send_attachments.side_effect = side_effect - with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) as send_webhook: + with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) as send_webhook: with self.subTest(side_effect=type(side_effect).__name__): with self.assertNotLogs(logger=log, level=logging.ERROR): await self.cog.relay_message(message) self.assertEqual(send_webhook.call_count, 2) - @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) - @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): """The `relay_message` method should handle irretrievable attachments.""" message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) @@ -456,7 +456,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): channel.fetch_message.reset_mock() @patch(f"{MODULE_PATH}.DuckPond.is_staff") - @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" channel_id = 31415926535 @@ -491,8 +491,8 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): payload.emoji = self.duck_pond_emoji for duck_count, should_relay in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=helpers.AsyncMock) as relay_message: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: + with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: count_ducks.return_value = duck_count with self.subTest(duck_count=duck_count, should_relay=should_relay): await self.cog.on_raw_reaction_add(payload) @@ -526,7 +526,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): (constants.DuckPond.threshold + 1, True), ) for duck_count, should_re_add_checkmark in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: count_ducks.return_value = duck_count with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): await self.cog.on_raw_reaction_remove(payload) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index deae7ebad..f5e937356 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -34,7 +34,7 @@ class InformationCogTests(unittest.TestCase): """Test if the `role_info` command correctly returns the `moderator_role`.""" self.ctx.guild.roles.append(self.moderator_role) - self.cog.roles_info.can_run = helpers.AsyncMock() + self.cog.roles_info.can_run = unittest.mock.AsyncMock() self.cog.roles_info.can_run.return_value = True coroutine = self.cog.roles_info.callback(self.cog, self.ctx) @@ -72,7 +72,7 @@ class InformationCogTests(unittest.TestCase): self.ctx.guild.roles.append([dummy_role, admin_role]) - self.cog.role_info.can_run = helpers.AsyncMock() + self.cog.role_info.can_run = unittest.mock.AsyncMock() self.cog.role_info.can_run.return_value = True coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) @@ -174,7 +174,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): def setUp(self): """Common set-up steps done before for each test.""" self.bot = helpers.MockBot() - self.bot.api_client.get = helpers.AsyncMock() + self.bot.api_client.get = unittest.mock.AsyncMock() self.cog = information.Information(self.bot) self.member = helpers.MockMember(id=1234) @@ -345,10 +345,10 @@ class UserEmbedTests(unittest.TestCase): def setUp(self): """Common set-up steps done before for each test.""" self.bot = helpers.MockBot() - self.bot.api_client.get = helpers.AsyncMock() + self.bot.api_client.get = unittest.mock.AsyncMock() self.cog = information.Information(self.bot) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -360,7 +360,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Mr. Hemlock") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -372,7 +372,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -387,8 +387,8 @@ class UserEmbedTests(unittest.TestCase): self.assertIn("&Admins", embed.description) self.assertNotIn("&Everyone", embed.description) - @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=helpers.AsyncMock) - @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=helpers.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): """The embed should contain expanded infractions and nomination info in mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) @@ -423,7 +423,7 @@ class UserEmbedTests(unittest.TestCase): embed.description ) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=helpers.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): """The embed should contain only basic infraction data outside of mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) @@ -454,7 +454,7 @@ class UserEmbedTests(unittest.TestCase): embed.description ) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() @@ -467,7 +467,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): """The embed should be created with a blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() @@ -477,7 +477,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour.blurple()) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() @@ -529,7 +529,7 @@ class UserCommandTests(unittest.TestCase): with self.assertRaises(InChannelCheckFailure, msg=msg): asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" constants.STAFF_ROLES = [self.moderator_role.id] @@ -542,7 +542,7 @@ class UserCommandTests(unittest.TestCase): create_embed.assert_called_once_with(ctx, self.author) ctx.send.assert_called_once() - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): """A user should target itself with `!user` when a `user` argument was not provided.""" constants.STAFF_ROLES = [self.moderator_role.id] @@ -555,7 +555,7 @@ class UserCommandTests(unittest.TestCase): create_embed.assert_called_once_with(ctx, self.author) ctx.send.assert_called_once() - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): """Staff members should be able to bypass the bot-commands channel restriction.""" constants.STAFF_ROLES = [self.moderator_role.id] @@ -568,7 +568,7 @@ class UserCommandTests(unittest.TestCase): create_embed.assert_called_once_with(ctx, self.moderator) ctx.send.assert_called_once() - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) def test_moderators_can_target_another_member(self, create_embed, constants): """A moderator should be able to use `!user` targeting another user.""" constants.MODERATION_ROLES = [self.moderator_role.id] diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index a54b839d7..33d1ec170 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -1,7 +1,7 @@ import asyncio import logging import unittest -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from discord import Colour @@ -11,7 +11,7 @@ from bot.cogs.token_remover import ( setup as setup_cog, ) from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import AsyncMock, MockBot, MockMessage +from tests.helpers import MockBot, MockMessage class TokenRemoverTests(unittest.TestCase): diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 69f35f2f5..de5724bca 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -1,12 +1,11 @@ import asyncio import unittest from datetime import datetime, timezone -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from dateutil.relativedelta import relativedelta from bot.utils import time -from tests.helpers import AsyncMock class TimeTests(unittest.TestCase): diff --git a/tests/helpers.py b/tests/helpers.py index 01752a791..506fe9894 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,11 +1,10 @@ from __future__ import annotations import collections -import inspect import itertools import logging import unittest.mock -from typing import Any, Iterable, Optional +from typing import Iterable, Optional import discord from discord.ext.commands import Context @@ -51,24 +50,31 @@ class CustomMockMixin: """ Provides common functionality for our custom Mock types. - The cooperative `__init__` automatically creates `AsyncMock` attributes for every coroutine - function `inspect` detects in the `spec` instance we provide. In addition, this mixin takes care - of making sure child mocks are instantiated with the correct class. By default, the mock of the - children will be `unittest.mock.MagicMock`, but this can be overwritten by setting the attribute - `child_mock_type` on the custom mock inheriting from this mixin. + The `_get_child_mock` method automatically returns an AsyncMock for coroutine methods of the mock + object. As discord.py also uses synchronous methods that nonetheless return coroutine objects, the + class attribute `additional_spec_asyncs` can be overwritten with an iterable containing additional + attribute names that should also mocked with an AsyncMock instead of a regular MagicMock/Mock. The + class method `spec_set` can be overwritten with the object that should be uses as the specification + for the mock. + + Mock/MagicMock subclasses that use this mixin only need to define `__init__` method if they need to + implement custom behavior. """ child_mock_type = unittest.mock.MagicMock discord_id = itertools.count(0) + spec_set = None + additional_spec_asyncs = None - def __init__(self, spec_set: Any = None, **kwargs): + def __init__(self, **kwargs): name = kwargs.pop('name', None) # `name` has special meaning for Mock classes, so we need to set it manually. - super().__init__(spec_set=spec_set, **kwargs) + super().__init__(spec_set=self.spec_set, **kwargs) + + if self.additional_spec_asyncs: + self._spec_asyncs.extend(self.additional_spec_asyncs) if name: self.name = name - if spec_set: - self._extract_coroutine_methods_from_spec_instance(spec_set) def _get_child_mock(self, **kw): """ @@ -82,7 +88,16 @@ class CustomMockMixin: This override will look for an attribute called `child_mock_type` and use that as the type of the child mock. """ - klass = self.child_mock_type + _new_name = kw.get("_new_name") + if _new_name in self.__dict__['_spec_asyncs']: + return unittest.mock.AsyncMock(**kw) + + _type = type(self) + if issubclass(_type, unittest.mock.MagicMock) and _new_name in unittest.mock._async_method_magics: + # Any asynchronous magic becomes an AsyncMock + klass = unittest.mock.AsyncMock + else: + klass = self.child_mock_type if self._mock_sealed: attribute = "." + kw["name"] if "name" in kw else "()" @@ -91,95 +106,6 @@ class CustomMockMixin: return klass(**kw) - def _extract_coroutine_methods_from_spec_instance(self, source: Any) -> None: - """Automatically detect coroutine functions in `source` and set them as AsyncMock attributes.""" - for name, _method in inspect.getmembers(source, inspect.iscoroutinefunction): - setattr(self, name, AsyncMock()) - - -# TODO: Remove me in Python 3.8 -class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): - """ - A MagicMock subclass to mock async callables. - - Python 3.8 will introduce an AsyncMock class in the standard library that will have some more - features; this stand-in only overwrites the `__call__` method to an async version. - """ - - async def __call__(self, *args, **kwargs): - return super().__call__(*args, **kwargs) - - -class AsyncIteratorMock: - """ - A class to mock asynchronous iterators. - - This allows async for, which is used in certain Discord.py objects. For example, - an async iterator is returned by the Reaction.users() method. - """ - - def __init__(self, iterable: Iterable = None): - if iterable is None: - iterable = [] - - self.iter = iter(iterable) - self.iterable = iterable - - self.call_count = 0 - - def __aiter__(self): - return self - - async def __anext__(self): - try: - return next(self.iter) - except StopIteration: - raise StopAsyncIteration - - def __call__(self): - """ - Keeps track of the number of times an instance has been called. - - This is useful, since it typically shows that the iterator has actually been used somewhere after we have - instantiated the mock for an attribute that normally returns an iterator when called. - """ - self.call_count += 1 - return self - - @property - def return_value(self): - """Makes `self.iterable` accessible as self.return_value.""" - return self.iterable - - @return_value.setter - def return_value(self, iterable): - """Stores the `return_value` as `self.iterable` and its iterator as `self.iter`.""" - self.iter = iter(iterable) - self.iterable = iterable - - def assert_called(self): - """Asserts if the AsyncIteratorMock instance has been called at least once.""" - if self.call_count == 0: - raise AssertionError("Expected AsyncIteratorMock to have been called.") - - def assert_called_once(self): - """Asserts if the AsyncIteratorMock instance has been called exactly once.""" - if self.call_count != 1: - raise AssertionError( - f"Expected AsyncIteratorMock to have been called once. Called {self.call_count} times." - ) - - def assert_not_called(self): - """Asserts if the AsyncIteratorMock instance has not been called.""" - if self.call_count != 0: - raise AssertionError( - f"Expected AsyncIteratorMock to not have been called once. Called {self.call_count} times." - ) - - def reset_mock(self): - """Resets the call count, but not the return value or iterator.""" - self.call_count = 0 - # Create a guild instance to get a realistic Mock of `discord.Guild` guild_data = { @@ -230,9 +156,11 @@ class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ + spec_set = guild_instance + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: default_kwargs = {'id': next(self.discord_id), 'members': []} - super().__init__(spec_set=guild_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: @@ -251,9 +179,11 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.Role` instances. For more information, see the `MockGuild` docstring. """ + spec_set = role_instance + def __init__(self, **kwargs) -> None: default_kwargs = {'id': next(self.discord_id), 'name': 'role', 'position': 1} - super().__init__(spec_set=role_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) if 'mention' not in kwargs: self.mention = f'&{self.name}' @@ -276,9 +206,11 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin Instances of this class will follow the specifications of `discord.Member` instances. For more information, see the `MockGuild` docstring. """ + spec_set = member_instance + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: default_kwargs = {'name': 'member', 'id': next(self.discord_id), 'bot': False} - super().__init__(spec_set=member_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: @@ -299,9 +231,11 @@ class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.User` instances. For more information, see the `MockGuild` docstring. """ + spec_set = user_instance + def __init__(self, **kwargs) -> None: default_kwargs = {'name': 'user', 'id': next(self.discord_id), 'bot': False} - super().__init__(spec_set=user_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) if 'mention' not in kwargs: self.mention = f"@{self.name}" @@ -320,14 +254,16 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. For more information, see the `MockGuild` docstring. """ + spec_set = bot_instance + additional_spec_asyncs = ("wait_for",) def __init__(self, **kwargs) -> None: - super().__init__(spec_set=bot_instance, **kwargs) + super().__init__(**kwargs) # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and # and should therefore be awaited. (The documentation calls it a coroutine as well, which # is technically incorrect, since it's a regular def.) - self.wait_for = AsyncMock() + # self.wait_for = unittest.mock.AsyncMock() # 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 @@ -358,10 +294,11 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ + spec_set = channel_instance def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()} - super().__init__(spec_set=channel_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) if 'mention' not in kwargs: self.mention = f"#{self.name}" @@ -400,9 +337,10 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Context` instances. For more information, see the `MockGuild` docstring. """ + spec_set = context_instance def __init__(self, **kwargs) -> None: - super().__init__(spec_set=context_instance, **kwargs) + super().__init__(**kwargs) self.bot = kwargs.get('bot', MockBot()) self.guild = kwargs.get('guild', MockGuild()) self.author = kwargs.get('author', MockMember()) @@ -419,8 +357,7 @@ class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Attachment` instances. For more information, see the `MockGuild` docstring. """ - def __init__(self, **kwargs) -> None: - super().__init__(spec_set=attachment_instance, **kwargs) + spec_set = attachment_instance class MockMessage(CustomMockMixin, unittest.mock.MagicMock): @@ -430,10 +367,11 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Message` instances. For more information, see the `MockGuild` docstring. """ + spec_set = message_instance def __init__(self, **kwargs) -> None: default_kwargs = {'attachments': []} - super().__init__(spec_set=message_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) @@ -449,9 +387,10 @@ class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Emoji` instances. For more information, see the `MockGuild` docstring. """ + spec_set = emoji_instance def __init__(self, **kwargs) -> None: - super().__init__(spec_set=emoji_instance, **kwargs) + super().__init__(**kwargs) self.guild = kwargs.get('guild', MockGuild()) @@ -465,9 +404,7 @@ class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For more information, see the `MockGuild` docstring. """ - - def __init__(self, **kwargs) -> None: - super().__init__(spec_set=partial_emoji_instance, **kwargs) + spec_set = partial_emoji_instance reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji()) @@ -480,12 +417,17 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Reaction` instances. For more information, see the `MockGuild` docstring. """ + spec_set = reaction_instance def __init__(self, **kwargs) -> None: - super().__init__(spec_set=reaction_instance, **kwargs) + _users = kwargs.pop("users", []) + super().__init__(**kwargs) self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) - self.users = AsyncIteratorMock(kwargs.get('users', [])) + + user_iterator = unittest.mock.AsyncMock() + user_iterator.__aiter__.return_value = _users + self.users.return_value = user_iterator webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock()) @@ -498,13 +440,5 @@ class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Webhook` instances. For more information, see the `MockGuild` docstring. """ - - def __init__(self, **kwargs) -> None: - super().__init__(spec_set=webhook_instance, **kwargs) - - # Because Webhooks can also use a synchronous "WebhookAdapter", the methods are not defined - # as coroutines. That's why we need to set the methods manually. - self.send = AsyncMock() - self.edit = AsyncMock() - self.delete = AsyncMock() - self.execute = AsyncMock() + spec_set = webhook_instance + additional_spec_asyncs = ("send", "edit", "delete", "execute") diff --git a/tests/test_helpers.py b/tests/test_helpers.py index fe39df308..81285e009 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,5 +1,4 @@ import asyncio -import inspect import unittest import unittest.mock @@ -214,6 +213,11 @@ class DiscordMocksTests(unittest.TestCase): with self.assertRaises(RuntimeError, msg="cannot reuse already awaited coroutine"): asyncio.run(coroutine_object) + def test_user_mock_uses_explicitly_passed_mention_attribute(self): + """MockUser should use an explicitly passed value for user.mention.""" + user = helpers.MockUser(mention="hello") + self.assertEqual(user.mention, "hello") + class MockObjectTests(unittest.TestCase): """Tests the mock objects and mixins we've defined.""" @@ -341,57 +345,10 @@ class MockObjectTests(unittest.TestCase): attribute = getattr(mock, valid_attribute) self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) - def test_extract_coroutine_methods_from_spec_instance_should_extract_all_and_only_coroutines(self): - """Test if all coroutine functions are extracted, but not regular methods or attributes.""" - class CoroutineDonor: - def __init__(self): - self.some_attribute = 'alpha' - - async def first_coroutine(): - """This coroutine function should be extracted.""" - - async def second_coroutine(): - """This coroutine function should be extracted.""" - - def regular_method(): - """This regular function should not be extracted.""" - - class Receiver: + def test_custom_mock_mixin_mocks_async_magic_methods_with_async_mock(self): + """The CustomMockMixin should mock async magic methods with an AsyncMock.""" + class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock): pass - donor = CoroutineDonor() - receiver = Receiver() - - helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance(receiver, donor) - - self.assertIsInstance(receiver.first_coroutine, helpers.AsyncMock) - self.assertIsInstance(receiver.second_coroutine, helpers.AsyncMock) - self.assertFalse(hasattr(receiver, 'regular_method')) - self.assertFalse(hasattr(receiver, 'some_attribute')) - - @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) - @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") - def test_custom_mock_mixin_init_with_spec(self, extract_method_mock): - """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" - spec_set = "pydis" - - helpers.CustomMockMixin(spec_set=spec_set) - - extract_method_mock.assert_called_once_with(spec_set) - - @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) - @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") - def test_custom_mock_mixin_init_without_spec(self, extract_method_mock): - """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" - helpers.CustomMockMixin() - - extract_method_mock.assert_not_called() - - def test_async_mock_provides_coroutine_for_dunder_call(self): - """Test if AsyncMock objects have a coroutine for their __call__ method.""" - async_mock = helpers.AsyncMock() - self.assertTrue(inspect.iscoroutinefunction(async_mock.__call__)) - - coroutine = async_mock() - self.assertTrue(inspect.iscoroutine(coroutine)) - self.assertIsNotNone(asyncio.run(coroutine)) + mock = MyMock() + self.assertIsInstance(mock.__aenter__, unittest.mock.AsyncMock) -- cgit v1.2.3 From f67cb7ac61eee86419d10e23e3fd3c66f1f9312e Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 20:58:20 +0100 Subject: Fix test_time test and ensure coverage One of the test_time methods did not actually assert the exception message it was trying to detect as the assertion statement was contained within the context manager handling the exception. I've moved it out of the context so it actually runs. I've also added a few `praga: no cover` comments for parts that were artifically lowering coverage of the test suite. --- tests/bot/cogs/test_duck_pond.py | 2 +- tests/bot/rules/__init__.py | 4 ++-- tests/bot/utils/test_time.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index e164f7544..7370b8471 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -312,7 +312,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): self.cog.webhook = helpers.MockAsyncWebhook() log = logging.getLogger("bot.cogs.duck_pond") - for side_effect in side_effects: + for side_effect in side_effects: # pragma: no cover send_attachments.side_effect = side_effect with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) as send_webhook: with self.subTest(side_effect=type(side_effect).__name__): diff --git a/tests/bot/rules/__init__.py b/tests/bot/rules/__init__.py index 0233e7939..0d570f5a3 100644 --- a/tests/bot/rules/__init__.py +++ b/tests/bot/rules/__init__.py @@ -68,9 +68,9 @@ class RuleTest(unittest.IsolatedAsyncioTestCase, metaclass=ABCMeta): @abstractmethod def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: """Give expected relevant messages for `case`.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover @abstractmethod def get_report(self, case: DisallowedCase) -> str: """Give expected error report for `case`.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index de5724bca..694d3a40f 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -43,7 +43,7 @@ class TimeTests(unittest.TestCase): for max_units in test_cases: with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error: time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) - self.assertEqual(str(error), 'max_units must be positive') + self.assertEqual(str(error.exception), 'max_units must be positive') def test_parse_rfc1123(self): """Testing parse_rfc1123.""" -- cgit v1.2.3 From f12d76dca1073b489b4a407a17dbab623aa0dce5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:18:01 -0800 Subject: Config: rename roles to match their names in the guild --- config-default.yml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/config-default.yml b/config-default.yml index 379475907..5755b2191 100644 --- a/config-default.yml +++ b/config-default.yml @@ -160,20 +160,20 @@ guild: reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] roles: - admin: &ADMIN_ROLE 267628507062992896 - announcements: 463658397560995840 - champion: 430492892331769857 - contributor: 295488872404484098 - core_developer: 587606783669829632 - helpers: 267630620367257601 - jammer: 591786436651646989 - moderator: &MOD_ROLE 267629731250176001 - muted: &MUTED_ROLE 277914926603829249 - owner: &OWNER_ROLE 267627879762755584 - partners: 323426753857191936 - rockstars: &ROCKSTARS_ROLE 458226413825294336 - team_leader: 501324292341104650 - verified: 352427296948486144 + admins: &ADMINS_ROLE 267628507062992896 + announcements: 463658397560995840 + code_jam_champions: 430492892331769857 + contributors: 295488872404484098 + core_developers: 587606783669829632 + developers: 352427296948486144 + helpers: 267630620367257601 + jammers: 591786436651646989 + moderators: &MODS_ROLE 267629731250176001 + muted: &MUTED_ROLE 277914926603829249 + owners: &OWNERS_ROLE 267627879762755584 + partners: 323426753857191936 + python_community: &PYTHON_COMMUNITY_ROLE 458226413825294336 + team_leaders: 501324292341104650 webhooks: talent_pool: 569145364800602132 @@ -267,10 +267,10 @@ filter: - *USER_EVENT_A role_whitelist: - - *ADMIN_ROLE - - *MOD_ROLE - - *OWNER_ROLE - - *ROCKSTARS_ROLE + - *ADMINS_ROLE + - *MODS_ROLE + - *OWNERS_ROLE + - *PYTHON_COMMUNITY_ROLE keys: -- cgit v1.2.3 From ffdde0f8a2f14590baf47462143fbd685d851fad Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:20:54 -0800 Subject: Config: split roles into categories --- config-default.yml | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/config-default.yml b/config-default.yml index 5755b2191..203f9e64e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -160,26 +160,30 @@ guild: reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] roles: - admins: &ADMINS_ROLE 267628507062992896 announcements: 463658397560995840 - code_jam_champions: 430492892331769857 contributors: 295488872404484098 - core_developers: 587606783669829632 developers: 352427296948486144 - helpers: 267630620367257601 - jammers: 591786436651646989 - moderators: &MODS_ROLE 267629731250176001 muted: &MUTED_ROLE 277914926603829249 - owners: &OWNERS_ROLE 267627879762755584 partners: 323426753857191936 python_community: &PYTHON_COMMUNITY_ROLE 458226413825294336 - team_leaders: 501324292341104650 + + # Staff + admins: &ADMINS_ROLE 267628507062992896 + core_developers: 587606783669829632 + helpers: 267630620367257601 + moderators: &MODS_ROLE 267629731250176001 + owners: &OWNERS_ROLE 267627879762755584 + + # Code Jam + code_jam_champions: 430492892331769857 + jammers: 591786436651646989 + team_leaders: 501324292341104650 webhooks: - talent_pool: 569145364800602132 - big_brother: 569133704568373283 - reddit: 635408384794951680 - duck_pond: 637821475327311927 + talent_pool: 569145364800602132 + big_brother: 569133704568373283 + reddit: 635408384794951680 + duck_pond: 637821475327311927 filter: -- cgit v1.2.3 From bf7dd8c17dea3d0c12139893c210500fdad820f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:46:12 -0800 Subject: Config: split channels into categories --- config-default.yml | 60 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/config-default.yml b/config-default.yml index 203f9e64e..415ed13f4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -113,19 +113,32 @@ guild: python_help: 356013061213126657 channels: - admins: &ADMINS 365960823622991872 - admin_spam: &ADMIN_SPAM 563594791770914816 - admins_voice: &ADMINS_VOICE 500734494840717332 announcements: 354619224620138496 - attachment_log: &ATTCH_LOG 649243850006855680 - big_brother_logs: &BBLOGS 468507907357409333 - bot: &BOT_CMD 267659945086812160 checkpoint_test: 422077681434099723 - defcon: &DEFCON 464469101889454091 + user_event_a: &USER_EVENT_A 592000283102674944 + + # Development devcontrib: &DEV_CONTRIB 635950537262759947 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 - esoteric: 470884583684964352 + + # Discussion + meta: 429409067623251969 + python: 267624335836053506 + + # Logs + attachment_log: &ATTCH_LOG 649243850006855680 + message_log: &MESSAGE_LOG 467752170159079424 + modlog: &MODLOG 282638479504965634 + userlog: 528976905546760203 + voice_log: 640292421988646961 + + # Off-topic + off_topic_0: 291284109232308226 + off_topic_1: 463035241142026251 + off_topic_2: 463035268514185226 + + # Python Help help_0: 303906576991780866 help_1: 303906556754395136 help_2: 303906514266226689 @@ -134,26 +147,31 @@ guild: help_5: 454941769734422538 help_6: 587375753306570782 help_7: 587375768556797982 + + # Special + bot: &BOT_CMD 267659945086812160 + esoteric: 470884583684964352 + reddit: 458224812528238616 + verification: 352442727016693763 + + # Staff + admins: &ADMINS 365960823622991872 + admin_spam: &ADMIN_SPAM 563594791770914816 + defcon: &DEFCON 464469101889454091 helpers: &HELPERS 385474242440986624 - message_log: &MESSAGE_LOG 467752170159079424 - meta: 429409067623251969 - mod_spam: &MOD_SPAM 620607373828030464 mods: &MODS 305126844661760000 mod_alerts: 473092532147060736 - modlog: &MODLOG 282638479504965634 - off_topic_0: 291284109232308226 - off_topic_1: 463035241142026251 - off_topic_2: 463035268514185226 + mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 - python: 267624335836053506 - reddit: 458224812528238616 staff_lounge: &STAFF_LOUNGE 464905259261755392 + + # Voice + admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 + + # Watch + big_brother_logs: &BBLOGS 468507907357409333 talent_pool: &TALENT_POOL 534321732593647616 - userlog: 528976905546760203 - user_event_a: &USER_EVENT_A 592000283102674944 - verification: 352442727016693763 - voice_log: 640292421988646961 staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG] -- cgit v1.2.3 From 707d2f7208e7008f94bfff1a1e13664dc38c6d6c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:47:11 -0800 Subject: Config: shorten name of PYTHON_COMMUNITY_ROLE --- config-default.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/config-default.yml b/config-default.yml index 415ed13f4..1e478154f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -178,12 +178,12 @@ guild: reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] roles: - announcements: 463658397560995840 - contributors: 295488872404484098 - developers: 352427296948486144 - muted: &MUTED_ROLE 277914926603829249 - partners: 323426753857191936 - python_community: &PYTHON_COMMUNITY_ROLE 458226413825294336 + announcements: 463658397560995840 + contributors: 295488872404484098 + developers: 352427296948486144 + muted: &MUTED_ROLE 277914926603829249 + partners: 323426753857191936 + python_community: &PY_COMMUNITY_ROLE 458226413825294336 # Staff admins: &ADMINS_ROLE 267628507062992896 @@ -292,7 +292,7 @@ filter: - *ADMINS_ROLE - *MODS_ROLE - *OWNERS_ROLE - - *PYTHON_COMMUNITY_ROLE + - *PY_COMMUNITY_ROLE keys: -- cgit v1.2.3 From 8c1a31b7d043facd876b22800e8cb44ae15d9492 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:50:21 -0800 Subject: Config: remove checkpoint_test and devtest They no longer exist in the guild. * Move devlog under the "Logs" category --- bot/cogs/bot.py | 1 - bot/cogs/tags.py | 1 - bot/constants.py | 2 -- config-default.yml | 2 -- 4 files changed, 6 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 73b1e8f41..74e882e0e 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -40,7 +40,6 @@ class BotCog(Cog, name="Bot"): # These channels will also work, but will not be subject to cooldown self.channel_whitelist = ( Channels.bot, - Channels.devtest, ) # Stores improperly formatted Python codeblock message ids and the corresponding bot message diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index b6360dfae..a38f5617f 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -15,7 +15,6 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) TEST_CHANNELS = ( - Channels.devtest, Channels.bot, Channels.helpers ) diff --git a/bot/constants.py b/bot/constants.py index 681d8da49..15f078cbf 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -363,11 +363,9 @@ class Channels(metaclass=YAMLGetter): attachment_log: int big_brother_logs: int bot: int - checkpoint_test: int defcon: int devcontrib: int devlog: int - devtest: int esoteric: int help_0: int help_1: int diff --git a/config-default.yml b/config-default.yml index 1e478154f..058317262 100644 --- a/config-default.yml +++ b/config-default.yml @@ -114,13 +114,11 @@ guild: channels: announcements: 354619224620138496 - checkpoint_test: 422077681434099723 user_event_a: &USER_EVENT_A 592000283102674944 # Development devcontrib: &DEV_CONTRIB 635950537262759947 devlog: &DEVLOG 622895325144940554 - devtest: &DEVTEST 414574275865870337 # Discussion meta: 429409067623251969 -- cgit v1.2.3 From a850a32135ef3b454abfe2ad017a46badd73d3c1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:54:58 -0800 Subject: Constants: rename roles to match their names in the guild --- bot/cogs/defcon.py | 12 ++++++------ bot/cogs/eval.py | 4 ++-- bot/cogs/extensions.py | 2 +- bot/cogs/jams.py | 6 +++--- bot/cogs/moderation/scheduler.py | 2 +- bot/cogs/snekbox.py | 2 +- bot/cogs/tags.py | 2 +- bot/cogs/verification.py | 2 +- bot/constants.py | 22 +++++++++++----------- tests/bot/cogs/test_information.py | 2 +- 10 files changed, 28 insertions(+), 28 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index a0d8fedd5..c7ea1f2bf 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -69,7 +69,7 @@ class Defcon(Cog): except Exception: # Yikes! log.exception("Unable to get DEFCON settings!") await self.bot.get_channel(Channels.devlog).send( - f"<@&{Roles.admin}> **WARNING**: Unable to get DEFCON settings!" + f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" ) else: @@ -118,7 +118,7 @@ class Defcon(Cog): ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" await ctx.invoke(self.bot.get_command("help"), "defcon") @@ -146,7 +146,7 @@ class Defcon(Cog): await self.send_defcon_log(action, ctx.author, error) @defcon_group.command(name='enable', aliases=('on', 'e')) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def enable_command(self, ctx: Context) -> None: """ Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! @@ -159,7 +159,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd')) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False @@ -167,7 +167,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def status_command(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed( @@ -179,7 +179,7 @@ class Defcon(Cog): await ctx.send(embed=embed) @defcon_group.command(name='days') - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def days_command(self, ctx: Context, days: int) -> None: """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 9c729f28a..52136fc8d 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -174,14 +174,14 @@ async def func(): # (None,) -> Any await ctx.send(f"```py\n{out}```", embed=embed) @group(name='internal', aliases=('int',)) - @with_role(Roles.owner, Roles.admin) + @with_role(Roles.owners, Roles.admins) async def internal_group(self, ctx: Context) -> None: """Internal commands. Top secret!""" if not ctx.invoked_subcommand: await ctx.invoke(self.bot.get_command("help"), "internal") @internal_group.command(name='eval', aliases=('e',)) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def eval(self, ctx: Context, *, code: str) -> None: """Run eval in a REPL-like format.""" code = code.strip("`") diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index f16e79fb7..b312e1a1d 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -221,7 +221,7 @@ class Extensions(commands.Cog): # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: """Only allow moderators and core developers to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developer) + return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 985f28ce5..1d062b0c2 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -18,7 +18,7 @@ class CodeJams(commands.Cog): self.bot = bot @commands.command() - @with_role(Roles.admin) + @with_role(Roles.admins) async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: """ Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. @@ -95,10 +95,10 @@ class CodeJams(commands.Cog): ) # Assign team leader role - await members[0].add_roles(ctx.guild.get_role(Roles.team_leader)) + await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders)) # Assign rest of roles - jammer_role = ctx.guild.get_role(Roles.jammer) + jammer_role = ctx.guild.get_role(Roles.jammers) for member in members: await member.add_roles(jammer_role) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index c0de0e4da..db1a3030e 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -307,7 +307,7 @@ class InfractionScheduler(Scheduler): Infractions of unsupported types will raise a ValueError. """ guild = self.bot.get_guild(constants.Guild.id) - mod_role = guild.get_role(constants.Roles.moderator) + mod_role = guild.get_role(constants.Roles.moderators) user_id = infraction["user"] actor = infraction["actor"] type_ = infraction["type"] diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index da33e27b2..84457e38f 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -34,7 +34,7 @@ RAW_CODE_REGEX = re.compile( ) MAX_PASTE_LEN = 1000 -EVAL_ROLES = (Roles.helpers, Roles.moderator, Roles.admin, Roles.owner, Roles.rockstars, Roles.partners) +EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) class Snekbox(Cog): diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index a38f5617f..2c4fa02bd 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -220,7 +220,7 @@ class Tags(Cog): )) @tags_group.command(name='delete', aliases=('remove', 'rm', 'd')) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None: """Remove a tag from the database.""" await self.bot.api_client.delete(f'bot/tags/{tag_name}') diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 582237374..09bef80c4 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -38,7 +38,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! PERIODIC_PING = ( f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`." - f" If you encounter any problems during the verification process, ping the <@&{Roles.admin}> role in this channel." + f" If you encounter any problems during the verification process, ping the <@&{Roles.admins}> role in this channel." ) BOT_MESSAGE_DELETE_DELAY = 10 diff --git a/bot/constants.py b/bot/constants.py index 15f078cbf..03578fefd 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -409,19 +409,19 @@ class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" - admin: int + admins: int announcements: int - champion: int - contributor: int - core_developer: int + code_jam_champions: int + contributors: int + core_developers: int helpers: int - jammer: int - moderator: int + jammers: int + moderators: int muted: int - owner: int + owners: int partners: int - rockstars: int - team_leader: int + python_community: int + team_leaders: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. @@ -570,8 +570,8 @@ BOT_DIR = os.path.dirname(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) # Default role combinations -MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner -STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner +MODERATION_ROLES = Roles.moderators, Roles.admins, Roles.owners +STAFF_ROLES = Roles.helpers, Roles.moderators, Roles.admins, Roles.owners # Roles combinations STAFF_CHANNELS = Guild.staff_channels diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index deae7ebad..38293269f 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -19,7 +19,7 @@ class InformationCogTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderator) + cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderators) def setUp(self): """Sets up fresh objects for each test.""" -- cgit v1.2.3 From ed25a7ae240cce1162a0a67fa6451363671cc7de Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:58:51 -0800 Subject: Constants: rename developers role back to verified It makes the code which uses it more readable. A comment was added to explain the discrepancy between the constant's name and the name in the guild. --- config-default.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 058317262..dc5be5d47 100644 --- a/config-default.yml +++ b/config-default.yml @@ -178,11 +178,13 @@ guild: roles: announcements: 463658397560995840 contributors: 295488872404484098 - developers: 352427296948486144 muted: &MUTED_ROLE 277914926603829249 partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 + # This is the Developers role on PyDis, here named verified for readability reasons + verified: 352427296948486144 + # Staff admins: &ADMINS_ROLE 267628507062992896 core_developers: 587606783669829632 -- cgit v1.2.3 From e38990dc26fd77323e33da267ab8f16538f32438 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:59:38 -0800 Subject: Constants: remove code jam champions role Nothing was using it. --- bot/constants.py | 1 - config-default.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 03578fefd..98914fb9d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -411,7 +411,6 @@ class Roles(metaclass=YAMLGetter): admins: int announcements: int - code_jam_champions: int contributors: int core_developers: int helpers: int diff --git a/config-default.yml b/config-default.yml index dc5be5d47..10cc34816 100644 --- a/config-default.yml +++ b/config-default.yml @@ -193,7 +193,6 @@ guild: owners: &OWNERS_ROLE 267627879762755584 # Code Jam - code_jam_champions: 430492892331769857 jammers: 591786436651646989 team_leaders: 501324292341104650 -- cgit v1.2.3 From 734fd8ff1a0e5bc309b0d84e34b2b6a1d0c204d9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 12:16:32 -0800 Subject: Config: rename channels to match their names in the guild --- bot/cogs/bot.py | 4 +- bot/cogs/clean.py | 2 +- bot/cogs/defcon.py | 2 +- bot/cogs/free.py | 2 +- bot/cogs/help.py | 2 +- bot/cogs/information.py | 6 +-- bot/cogs/logging.py | 2 +- bot/cogs/moderation/modlog.py | 12 +++--- bot/cogs/snekbox.py | 2 +- bot/cogs/tags.py | 2 +- bot/cogs/utils.py | 2 +- bot/cogs/verification.py | 9 +++-- bot/constants.py | 12 +++--- config-default.yml | 93 +++++++++++++++++++++---------------------- 14 files changed, 76 insertions(+), 76 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 74e882e0e..f17135877 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -34,12 +34,12 @@ class BotCog(Cog, name="Bot"): Channels.help_5: 0, Channels.help_6: 0, Channels.help_7: 0, - Channels.python: 0, + Channels.python_discussion: 0, } # These channels will also work, but will not be subject to cooldown self.channel_whitelist = ( - Channels.bot, + Channels.bot_commands, ) # Stores improperly formatted Python codeblock message ids and the corresponding bot message diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 2104efe57..5cdf0b048 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -173,7 +173,7 @@ class Clean(Cog): colour=Colour(Colours.soft_red), title="Bulk message delete", text=message, - channel_id=Channels.modlog, + channel_id=Channels.mod_log, ) @group(invoke_without_command=True, name="clean", aliases=["purge"]) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index c7ea1f2bf..050760a71 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -68,7 +68,7 @@ class Defcon(Cog): except Exception: # Yikes! log.exception("Unable to get DEFCON settings!") - await self.bot.get_channel(Channels.devlog).send( + await self.bot.get_channel(Channels.dev_log).send( f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" ) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index 49cab6172..02c02d067 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -22,7 +22,7 @@ class Free(Cog): PYTHON_HELP_ID = Categories.python_help @command(name="free", aliases=('f',)) - @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None: """ Lists free help channels by likeliness of availability. diff --git a/bot/cogs/help.py b/bot/cogs/help.py index fd5bbc3ca..744722220 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -507,7 +507,7 @@ class Help(DiscordCog): """Custom Embed Pagination Help feature.""" @commands.command('help') - @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) async def new_help(self, ctx: Context, *commands) -> None: """Shows Command Help.""" try: diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 13c8aabaa..49beca15b 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -152,8 +152,8 @@ class Information(Cog): # Non-staff may only do this in #bot-commands if not with_role_check(ctx, *constants.STAFF_ROLES): - if not ctx.channel.id == constants.Channels.bot: - raise InChannelCheckFailure(constants.Channels.bot) + if not ctx.channel.id == constants.Channels.bot_commands: + raise InChannelCheckFailure(constants.Channels.bot_commands) embed = await self.create_user_embed(ctx, user) @@ -332,7 +332,7 @@ class Information(Cog): @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) @group(invoke_without_command=True) - @in_channel(constants.Channels.bot, bypass_roles=constants.STAFF_ROLES) + @in_channel(constants.Channels.bot_commands, bypass_roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index d1b7dcab3..9dcb1456b 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -34,7 +34,7 @@ class Logging(Cog): ) if not DEBUG_MODE: - await self.bot.get_channel(Channels.devlog).send(embed=embed) + await self.bot.get_channel(Channels.dev_log).send(embed=embed) def setup(bot: Bot) -> None: diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index e8ae0dbe6..94e646248 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -87,7 +87,7 @@ class ModLog(Cog, name="ModLog"): title: t.Optional[str], text: str, thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, - channel_id: int = Channels.modlog, + channel_id: int = Channels.mod_log, ping_everyone: bool = False, files: t.Optional[t.List[discord.File]] = None, content: t.Optional[str] = None, @@ -377,7 +377,7 @@ class ModLog(Cog, name="ModLog"): Icons.user_ban, Colours.soft_red, "User banned", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.userlog + channel_id=Channels.user_log ) @Cog.listener() @@ -399,7 +399,7 @@ class ModLog(Cog, name="ModLog"): Icons.sign_in, Colours.soft_green, "User joined", message, thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.userlog + channel_id=Channels.user_log ) @Cog.listener() @@ -416,7 +416,7 @@ class ModLog(Cog, name="ModLog"): Icons.sign_out, Colours.soft_red, "User left", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.userlog + channel_id=Channels.user_log ) @Cog.listener() @@ -433,7 +433,7 @@ class ModLog(Cog, name="ModLog"): Icons.user_unban, Colour.blurple(), "User unbanned", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.modlog + channel_id=Channels.mod_log ) @Cog.listener() @@ -529,7 +529,7 @@ class ModLog(Cog, name="ModLog"): Icons.user_update, Colour.blurple(), "Member updated", message, thumbnail=after.avatar_url_as(static_format="png"), - channel_id=Channels.userlog + channel_id=Channels.user_log ) @Cog.listener() diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 84457e38f..aef12546d 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -177,7 +177,7 @@ class Snekbox(Cog): @command(name="eval", aliases=("e",)) @guild_only() - @in_channel(Channels.bot, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) + @in_channel(Channels.bot_commands, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) async def eval_command(self, ctx: Context, *, code: str = None) -> None: """ Run Python code and get the results. diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 2c4fa02bd..5da9a4148 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -15,7 +15,7 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) TEST_CHANNELS = ( - Channels.bot, + Channels.bot_commands, Channels.helpers ) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index da278011a..94b9d6b5a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -89,7 +89,7 @@ class Utils(Cog): await ctx.message.channel.send(embed=pep_embed) @command() - @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) + @in_channel(Channels.bot_commands, bypass_roles=STAFF_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 25 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 09bef80c4..94bef3188 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -30,10 +30,11 @@ your information removed here as well. Feel free to review them at any point! Additionally, if you'd like to receive notifications for the announcements we post in <#{Channels.announcements}> \ -from time to time, you can send `!subscribe` to <#{Channels.bot}> at any time to assign yourself the \ +from time to time, you can send `!subscribe` to <#{Channels.bot_commands}> at any time to assign yourself the \ **Announcements** role. We'll mention this role every time we make an announcement. -If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to <#{Channels.bot}>. +If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ +<#{Channels.bot_commands}>. """ PERIODIC_PING = ( @@ -136,7 +137,7 @@ class Verification(Cog): await ctx.message.delete() @command(name='subscribe') - @in_channel(Channels.bot) + @in_channel(Channels.bot_commands) async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Subscribe to announcement notifications by assigning yourself the role.""" has_role = False @@ -160,7 +161,7 @@ class Verification(Cog): ) @command(name='unsubscribe') - @in_channel(Channels.bot) + @in_channel(Channels.bot_commands) async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Unsubscribe from announcement notifications by removing the role from yourself.""" has_role = False diff --git a/bot/constants.py b/bot/constants.py index 98914fb9d..f35d608da 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -362,10 +362,10 @@ class Channels(metaclass=YAMLGetter): announcements: int attachment_log: int big_brother_logs: int - bot: int + bot_commands: int defcon: int devcontrib: int - devlog: int + dev_log: int esoteric: int help_0: int help_1: int @@ -381,16 +381,16 @@ class Channels(metaclass=YAMLGetter): mod_spam: int mods: int mod_alerts: int - modlog: int + mod_log: int off_topic_0: int off_topic_1: int off_topic_2: int organisation: int - python: int + python_discussion: int reddit: int talent_pool: int - userlog: int - user_event_a: int + user_log: int + user_event_announcements: int verification: int voice_log: int diff --git a/config-default.yml b/config-default.yml index 10cc34816..44da694c2 100644 --- a/config-default.yml +++ b/config-default.yml @@ -110,69 +110,69 @@ guild: id: 267624335836053506 categories: - python_help: 356013061213126657 + python_help: 356013061213126657 channels: - announcements: 354619224620138496 - user_event_a: &USER_EVENT_A 592000283102674944 + announcements: 354619224620138496 + user_event_announcements: &USER_EVENT_A 592000283102674944 # Development - devcontrib: &DEV_CONTRIB 635950537262759947 - devlog: &DEVLOG 622895325144940554 + devcontrib: &DEV_CONTRIB 635950537262759947 + dev_log: &DEVLOG 622895325144940554 # Discussion - meta: 429409067623251969 - python: 267624335836053506 + meta: 429409067623251969 + python_discussion: 267624335836053506 # Logs - attachment_log: &ATTCH_LOG 649243850006855680 - message_log: &MESSAGE_LOG 467752170159079424 - modlog: &MODLOG 282638479504965634 - userlog: 528976905546760203 - voice_log: 640292421988646961 + attachment_log: &ATTACH_LOG 649243850006855680 + message_log: &MESSAGE_LOG 467752170159079424 + mod_log: &MOD_LOG 282638479504965634 + user_log: 528976905546760203 + voice_log: 640292421988646961 # Off-topic - off_topic_0: 291284109232308226 - off_topic_1: 463035241142026251 - off_topic_2: 463035268514185226 + off_topic_0: 291284109232308226 + off_topic_1: 463035241142026251 + off_topic_2: 463035268514185226 # Python Help - help_0: 303906576991780866 - help_1: 303906556754395136 - help_2: 303906514266226689 - help_3: 439702951246692352 - help_4: 451312046647148554 - help_5: 454941769734422538 - help_6: 587375753306570782 - help_7: 587375768556797982 + help_0: 303906576991780866 + help_1: 303906556754395136 + help_2: 303906514266226689 + help_3: 439702951246692352 + help_4: 451312046647148554 + help_5: 454941769734422538 + help_6: 587375753306570782 + help_7: 587375768556797982 # Special - bot: &BOT_CMD 267659945086812160 - esoteric: 470884583684964352 - reddit: 458224812528238616 - verification: 352442727016693763 + bot_commands: &BOT_CMD 267659945086812160 + esoteric: 470884583684964352 + reddit: 458224812528238616 + verification: 352442727016693763 # Staff - admins: &ADMINS 365960823622991872 - admin_spam: &ADMIN_SPAM 563594791770914816 - defcon: &DEFCON 464469101889454091 - helpers: &HELPERS 385474242440986624 - mods: &MODS 305126844661760000 - mod_alerts: 473092532147060736 - mod_spam: &MOD_SPAM 620607373828030464 - organisation: &ORGANISATION 551789653284356126 - staff_lounge: &STAFF_LOUNGE 464905259261755392 + admins: &ADMINS 365960823622991872 + admin_spam: &ADMIN_SPAM 563594791770914816 + defcon: &DEFCON 464469101889454091 + helpers: &HELPERS 385474242440986624 + mods: &MODS 305126844661760000 + mod_alerts: 473092532147060736 + mod_spam: &MOD_SPAM 620607373828030464 + organisation: &ORGANISATION 551789653284356126 + staff_lounge: &STAFF_LOUNGE 464905259261755392 # Voice - admins_voice: &ADMINS_VOICE 500734494840717332 - staff_voice: &STAFF_VOICE 412375055910043655 + admins_voice: &ADMINS_VOICE 500734494840717332 + staff_voice: &STAFF_VOICE 412375055910043655 # Watch - big_brother_logs: &BBLOGS 468507907357409333 - talent_pool: &TALENT_POOL 534321732593647616 + big_brother_logs: &BB_LOGS 468507907357409333 + talent_pool: &TALENT_POOL 534321732593647616 staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] - ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG] + ignored: [*ADMINS, *MESSAGE_LOG, *MOD_LOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTACH_LOG] reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] roles: @@ -193,8 +193,8 @@ guild: owners: &OWNERS_ROLE 267627879762755584 # Code Jam - jammers: 591786436651646989 - team_leaders: 501324292341104650 + jammers: 591786436651646989 + team_leaders: 501324292341104650 webhooks: talent_pool: 569145364800602132 @@ -278,12 +278,11 @@ filter: # Censor doesn't apply to these channel_whitelist: - *ADMINS - - *MODLOG + - *MOD_LOG - *MESSAGE_LOG - - *DEVLOG - - *BBLOGS + - *DEV_LOG + - *BB_LOGS - *STAFF_LOUNGE - - *DEVTEST - *TALENT_POOL - *USER_EVENT_A -- cgit v1.2.3 From b387b2ec3201a33a0f2ba46a032477b126479671 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 12:23:25 -0800 Subject: Always load doc and verification extensions They used to only be loaded in "debug mode" because the main guild was used to test the bot. However, we have since moved to using a separate test guild so it's no longer a concern if these cogs get loaded. It was confusing to some contributors as to why these cogs were not being loaded since the debug mode isn't really documented anywhere. --- bot/__main__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 490163739..0079a9381 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -7,7 +7,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from bot import patches from bot.bot import Bot -from bot.constants import Bot as BotConfig, DEBUG_MODE +from bot.constants import Bot as BotConfig sentry_logging = LoggingIntegration( level=logging.TRACE, @@ -40,10 +40,8 @@ bot.load_extension("bot.cogs.clean") bot.load_extension("bot.cogs.extensions") bot.load_extension("bot.cogs.help") -# Only load this in production -if not DEBUG_MODE: - bot.load_extension("bot.cogs.doc") - bot.load_extension("bot.cogs.verification") +bot.load_extension("bot.cogs.doc") +bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") -- cgit v1.2.3 From 92e4e6d250d6de1b164d36b8a16c62dd4c71fa4f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 12:40:21 -0800 Subject: Config: fix DEV_LOG variable thingy --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index 44da694c2..51efe4d9a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -117,8 +117,8 @@ guild: user_event_announcements: &USER_EVENT_A 592000283102674944 # Development - devcontrib: &DEV_CONTRIB 635950537262759947 - dev_log: &DEVLOG 622895325144940554 + devcontrib: &DEV_CONTRIB 635950537262759947 + dev_log: &DEV_LOG 622895325144940554 # Discussion meta: 429409067623251969 -- cgit v1.2.3 From 14536f873d5d233880a88c9f71710ef7c2061625 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 12:40:57 -0800 Subject: Config: add underscore to devcontrib --- bot/constants.py | 2 +- config-default.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index f35d608da..63f7b15ee 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -364,7 +364,7 @@ class Channels(metaclass=YAMLGetter): big_brother_logs: int bot_commands: int defcon: int - devcontrib: int + dev_contrib: int dev_log: int esoteric: int help_0: int diff --git a/config-default.yml b/config-default.yml index 51efe4d9a..a43610562 100644 --- a/config-default.yml +++ b/config-default.yml @@ -117,7 +117,7 @@ guild: user_event_announcements: &USER_EVENT_A 592000283102674944 # Development - devcontrib: &DEV_CONTRIB 635950537262759947 + dev_contrib: &DEV_CONTRIB 635950537262759947 dev_log: &DEV_LOG 622895325144940554 # Discussion -- cgit v1.2.3 From 7c14787a9b1550328180ac3ce3da4d9faa65f41e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 13:05:36 -0800 Subject: Config: replace abbreviated lists with normal ones Lists were getting too long to be readable as one line. Having each element on a separate line also reduces merge conflicts. --- config-default.yml | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/config-default.yml b/config-default.yml index a43610562..05059fbee 100644 --- a/config-default.yml +++ b/config-default.yml @@ -171,9 +171,26 @@ guild: big_brother_logs: &BB_LOGS 468507907357409333 talent_pool: &TALENT_POOL 534321732593647616 - staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] - ignored: [*ADMINS, *MESSAGE_LOG, *MOD_LOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTACH_LOG] - reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] + staff_channels: + - *ADMINS + - *ADMIN_SPAM + - *DEFCON + - *HELPERS + - *MODS + - *MOD_SPAM + - *ORGANISATION + + ignored: + - *ADMINS + - *ADMINS_VOICE + - *ATTACH_LOG + - *MESSAGE_LOG + - *MOD_LOG + - *STAFF_VOICE + + reminder_whitelist: + - *BOT_CMD + - *DEV_CONTRIB roles: announcements: 463658397560995840 @@ -454,7 +471,20 @@ redirect_output: duck_pond: threshold: 5 - custom_emojis: [*DUCKY_YELLOW, *DUCKY_BLURPLE, *DUCKY_CAMO, *DUCKY_DEVIL, *DUCKY_NINJA, *DUCKY_REGAL, *DUCKY_TUBE, *DUCKY_HUNT, *DUCKY_WIZARD, *DUCKY_PARTY, *DUCKY_ANGEL, *DUCKY_MAUL, *DUCKY_SANTA] + custom_emojis: + - *DUCKY_YELLOW + - *DUCKY_BLURPLE + - *DUCKY_CAMO + - *DUCKY_DEVIL + - *DUCKY_NINJA + - *DUCKY_REGAL + - *DUCKY_TUBE + - *DUCKY_HUNT + - *DUCKY_WIZARD + - *DUCKY_PARTY + - *DUCKY_ANGEL + - *DUCKY_MAUL + - *DUCKY_SANTA config: required_keys: ['bot.token'] -- cgit v1.2.3 From 66fa960fcf0f1d11af20ec1c77039e9ca791f4dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 13:08:40 -0800 Subject: Constants: rename Guild.Constant.ignored to modlog_blacklist This name better explains what the list is for. --- bot/cogs/moderation/modlog.py | 10 +++++----- bot/constants.py | 2 +- config-default.yml | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 94e646248..59ae6b587 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -538,7 +538,7 @@ class ModLog(Cog, name="ModLog"): channel = message.channel author = message.author - if message.guild.id != GuildConstant.id or channel.id in GuildConstant.ignored: + if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist: return self._cached_deletes.append(message.id) @@ -591,7 +591,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.ignored: + if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.modlog_blacklist: return await asyncio.sleep(1) # Wait here in case the normal event was fired @@ -635,7 +635,7 @@ class ModLog(Cog, name="ModLog"): if ( not msg_before.guild or msg_before.guild.id != GuildConstant.id - or msg_before.channel.id in GuildConstant.ignored + or msg_before.channel.id in GuildConstant.modlog_blacklist or msg_before.author.bot ): return @@ -717,7 +717,7 @@ class ModLog(Cog, name="ModLog"): if ( not message.guild or message.guild.id != GuildConstant.id - or message.channel.id in GuildConstant.ignored + or message.channel.id in GuildConstant.modlog_blacklist or message.author.bot ): return @@ -769,7 +769,7 @@ 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.ignored) + or (before.channel and before.channel.id in GuildConstant.modlog_blacklist) ): return diff --git a/bot/constants.py b/bot/constants.py index 63f7b15ee..9855421c9 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -428,7 +428,7 @@ class Guild(metaclass=YAMLGetter): section = "guild" id: int - ignored: List[int] + modlog_blacklist: List[int] staff_channels: List[int] reminder_whitelist: List[int] diff --git a/config-default.yml b/config-default.yml index 05059fbee..b253f32e8 100644 --- a/config-default.yml +++ b/config-default.yml @@ -180,7 +180,8 @@ guild: - *MOD_SPAM - *ORGANISATION - ignored: + # Modlog cog ignores events which occur in these channels + modlog_blacklist: - *ADMINS - *ADMINS_VOICE - *ATTACH_LOG -- cgit v1.2.3 From 12c7d2794e8e9d086f5d52c8916df26bb9de5979 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 13:29:08 -0800 Subject: Tests: fix setting bot-commands ID in information tests --- tests/bot/cogs/test_information.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 38293269f..8443cfe71 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -521,7 +521,7 @@ class UserCommandTests(unittest.TestCase): """A regular user should not be able to use this command outside of bot-commands.""" constants.MODERATION_ROLES = [self.moderator_role.id] constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) @@ -533,7 +533,7 @@ class UserCommandTests(unittest.TestCase): def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) @@ -546,7 +546,7 @@ class UserCommandTests(unittest.TestCase): def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): """A user should target itself with `!user` when a `user` argument was not provided.""" constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) @@ -559,7 +559,7 @@ class UserCommandTests(unittest.TestCase): def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): """Staff members should be able to bypass the bot-commands channel restriction.""" constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) -- cgit v1.2.3 From 863f99eaece2612f3817780a084a3486d9cf4748 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 24 Feb 2020 00:00:55 +0100 Subject: Make Azure CI use Python 3.8 and Ubuntu 18.04 Since the bot is now using Python 3.8 and some of our dependencies have Python-version specfic dependencies, it's important that the CI, which installs from our Pipfile, uses the same version of Python. --- azure-pipelines.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 874364a6f..35dea089a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,7 +9,7 @@ jobs: - job: test displayName: 'Lint & Test' pool: - vmImage: ubuntu-16.04 + vmImage: ubuntu-18.04 variables: PIP_CACHE_DIR: ".cache/pip" @@ -18,7 +18,7 @@ jobs: - task: UsePythonVersion@0 displayName: 'Set Python version' inputs: - versionSpec: '3.7.x' + versionSpec: '3.8.x' addToPath: true - script: pip install pipenv -- cgit v1.2.3 From b9cafd1c4d6707467e5a2f336dd80eda97ba2f42 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 24 Feb 2020 01:00:45 +0100 Subject: Update Dockerfile to use python:3.8-slim --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 271c25050..22ebcd667 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.7-slim +FROM python:3.8-slim # Set pip to have cleaner logs and no saved cache ENV PIP_NO_CACHE_DIR=false \ -- cgit v1.2.3 From 8a3063be1764307d05ae0215b00f53b06ed33f6c Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 01:30:50 +0100 Subject: Implement `__iter__` on constants YAMLGetter. Python tries to fall back on passing indices to `__getitem__` without iter implemented; failing on the first line. --- bot/constants.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index a4c65a1f8..3ecdb5b35 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -186,6 +186,10 @@ class YAMLGetter(type): def __getitem__(cls, name): return cls.__getattr__(name) + def __iter__(cls): + """Returns iterator of key: value pairs of current constants class.""" + return iter(_CONFIG_YAML[cls.section][cls.subsection].items()) + # Dataclasses class Bot(metaclass=YAMLGetter): -- cgit v1.2.3 From 08f6ed038fa4be5ed09227114902a52d72a73155 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 01:33:23 +0100 Subject: Add ConfigVerifier cog. Adds ConfigVerifier which verifies channels when loaded. --- bot/__main__.py | 1 + bot/cogs/config_verifier.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 bot/cogs/config_verifier.py diff --git a/bot/__main__.py b/bot/__main__.py index 490163739..79f89b467 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -31,6 +31,7 @@ bot.load_extension("bot.cogs.error_handler") bot.load_extension("bot.cogs.filtering") bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.security") +bot.load_extension("bot.cogs.config_verifier") # Commands, etc bot.load_extension("bot.cogs.antimalware") diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py new file mode 100644 index 000000000..f0aaa06ea --- /dev/null +++ b/bot/cogs/config_verifier.py @@ -0,0 +1,40 @@ +import logging + +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot + + +log = logging.getLogger(__name__) + + +class ConfigVerifier(Cog): + """Verify config on startup.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.bot.loop.create_task(self.verify_channels()) + + async def verify_channels(self) -> None: + """ + Verifies channels in config. + + If any channels in config aren't present in server, log them in a warning. + """ + await self.bot.wait_until_ready() + server = self.bot.get_guild(constants.Guild.id) + + server_channel_ids = {channel.id for channel in server.channels} + invalid_channels = [ + channel_name for channel_name, channel_id in constants.Channels + if channel_id not in server_channel_ids + ] + + if invalid_channels: + log.warning(f"Channels do not exist in server: {', '.join(invalid_channels)}.") + + +def setup(bot: Bot) -> None: + """Load the ConfigVerifier cog.""" + bot.add_cog(ConfigVerifier(bot)) -- cgit v1.2.3 From a1ad4ae66bfa14972f9ea686a728e8060bfe55e0 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 01:43:43 +0100 Subject: Change warning text. --- bot/cogs/config_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py index f0aaa06ea..d2bc81ba6 100644 --- a/bot/cogs/config_verifier.py +++ b/bot/cogs/config_verifier.py @@ -32,7 +32,7 @@ class ConfigVerifier(Cog): ] if invalid_channels: - log.warning(f"Channels do not exist in server: {', '.join(invalid_channels)}.") + log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.") def setup(bot: Bot) -> None: -- cgit v1.2.3 From c7ffafeedc44fde40e3bd5dae6c95fbabc75a9d9 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 24 Feb 2020 02:10:22 +0100 Subject: Use realistic mixin implementation Instead of using the mixin class bare, I've now included into a class tha subclasses unittest.TestCase as that's how it's going to be used "in the wild". --- tests/test_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_base.py b/tests/test_base.py index 23abb1dfd..235a2ee6c 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -6,7 +6,7 @@ import unittest.mock from tests.base import LoggingTestsMixin, _CaptureLogHandler -class LoggingTestCase(LoggingTestsMixin): +class LoggingTestCase(LoggingTestsMixin, unittest.TestCase): pass -- cgit v1.2.3 From b8bd18bd743608ddff47064d0b459edff3da65e3 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 24 Feb 2020 02:12:02 +0100 Subject: Migrate syncers test suite to Python 3.8 The test suite for the new role/member syncers used the "old"-style test suite with the helpers implemented for Python 3.7. I have migrated it to use the new Python 3.8 asyncio test helpers. --- tests/base.py | 4 ++-- tests/bot/cogs/sync/test_base.py | 45 ++++++++++++++++----------------------- tests/bot/cogs/sync/test_cog.py | 31 ++++++++------------------- tests/bot/cogs/sync/test_roles.py | 12 ++--------- tests/bot/cogs/sync/test_users.py | 13 ++--------- tests/helpers.py | 4 +--- 6 files changed, 34 insertions(+), 75 deletions(-) diff --git a/tests/base.py b/tests/base.py index 21613110e..42174e911 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,4 +1,5 @@ import logging +import unittest from contextlib import contextmanager from typing import Dict @@ -77,10 +78,9 @@ class LoggingTestsMixin: self.fail(msg) -class CommandTestCase(unittest.TestCase): +class CommandTestCase(unittest.IsolatedAsyncioTestCase): """TestCase with additional assertions that are useful for testing Discord commands.""" - @helpers.async_test async def assertHasPermissionsCheck( self, cmd: commands.Command, diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index e6a6f9688..17aa4198b 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -13,8 +13,8 @@ class TestSyncer(Syncer): """Syncer subclass with mocks for abstract methods for testing purposes.""" name = "test" - _get_diff = helpers.AsyncMock() - _sync = helpers.AsyncMock() + _get_diff = mock.AsyncMock() + _sync = mock.AsyncMock() class SyncerBaseTests(unittest.TestCase): @@ -29,7 +29,7 @@ class SyncerBaseTests(unittest.TestCase): Syncer(self.bot) -class SyncerSendPromptTests(unittest.TestCase): +class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): """Tests for sending the sync confirmation prompt.""" def setUp(self): @@ -61,7 +61,6 @@ class SyncerSendPromptTests(unittest.TestCase): return mock_channel, mock_message - @helpers.async_test async def test_send_prompt_edits_and_returns_message(self): """The given message should be edited to display the prompt and then should be returned.""" msg = helpers.MockMessage() @@ -71,7 +70,6 @@ class SyncerSendPromptTests(unittest.TestCase): self.assertIn("content", msg.edit.call_args[1]) self.assertEqual(ret_val, msg) - @helpers.async_test async def test_send_prompt_gets_dev_core_channel(self): """The dev-core channel should be retrieved if an extant message isn't given.""" subtests = ( @@ -86,7 +84,6 @@ class SyncerSendPromptTests(unittest.TestCase): method.assert_called_once_with(constants.Channels.devcore) - @helpers.async_test async def test_send_prompt_returns_None_if_channel_fetch_fails(self): """None should be returned if there's an HTTPException when fetching the channel.""" self.bot.get_channel.return_value = None @@ -96,7 +93,6 @@ class SyncerSendPromptTests(unittest.TestCase): self.assertIsNone(ret_val) - @helpers.async_test async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): """A new message mentioning core devs should be sent and returned if message isn't given.""" for mock_ in (self.mock_get_channel, self.mock_fetch_channel): @@ -108,7 +104,6 @@ class SyncerSendPromptTests(unittest.TestCase): self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) self.assertEqual(ret_val, mock_message) - @helpers.async_test async def test_send_prompt_adds_reactions(self): """The message should have reactions for confirmation added.""" extant_message = helpers.MockMessage() @@ -129,7 +124,7 @@ class SyncerSendPromptTests(unittest.TestCase): mock_message.add_reaction.assert_has_calls(calls) -class SyncerConfirmationTests(unittest.TestCase): +class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): """Tests for waiting for a sync confirmation reaction on the prompt.""" def setUp(self): @@ -211,7 +206,6 @@ class SyncerConfirmationTests(unittest.TestCase): ret_val = self.syncer._reaction_check(*args) self.assertFalse(ret_val) - @helpers.async_test async def test_wait_for_confirmation(self): """The message should always be edited and only return True if the emoji is a check mark.""" subtests = ( @@ -251,14 +245,13 @@ class SyncerConfirmationTests(unittest.TestCase): self.assertIs(actual_return, ret_val) -class SyncerSyncTests(unittest.TestCase): +class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for main function orchestrating the sync.""" def setUp(self): self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) self.syncer = TestSyncer(self.bot) - @helpers.async_test async def test_sync_respects_confirmation_result(self): """The sync should abort if confirmation fails and continue if confirmed.""" mock_message = helpers.MockMessage() @@ -274,7 +267,7 @@ class SyncerSyncTests(unittest.TestCase): diff = _Diff({1, 2, 3}, {4, 5}, None) self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = helpers.AsyncMock( + self.syncer._get_confirmation_result = mock.AsyncMock( return_value=(confirmed, message) ) @@ -289,7 +282,6 @@ class SyncerSyncTests(unittest.TestCase): else: self.syncer._sync.assert_not_called() - @helpers.async_test async def test_sync_diff_size(self): """The diff size should be correctly calculated.""" subtests = ( @@ -303,7 +295,7 @@ class SyncerSyncTests(unittest.TestCase): with self.subTest(size=size, diff=diff): self.syncer._get_diff.reset_mock() self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) + self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) guild = helpers.MockGuild() await self.syncer.sync(guild) @@ -312,7 +304,6 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._get_confirmation_result.assert_called_once() self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) - @helpers.async_test async def test_sync_message_edited(self): """The message should be edited if one was sent, even if the sync has an API error.""" subtests = ( @@ -324,7 +315,7 @@ class SyncerSyncTests(unittest.TestCase): for message, side_effect, should_edit in subtests: with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): self.syncer._sync.side_effect = side_effect - self.syncer._get_confirmation_result = helpers.AsyncMock( + self.syncer._get_confirmation_result = mock.AsyncMock( return_value=(True, message) ) @@ -335,7 +326,6 @@ class SyncerSyncTests(unittest.TestCase): message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) - @helpers.async_test async def test_sync_confirmation_context_redirect(self): """If ctx is given, a new message should be sent and author should be ctx's author.""" mock_member = helpers.MockMember() @@ -349,7 +339,10 @@ class SyncerSyncTests(unittest.TestCase): if ctx is not None: ctx.send.return_value = message - self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) + diff = _Diff({1, 2, 3}, {4, 5}, None) + self.syncer._get_diff.return_value = diff + + self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) guild = helpers.MockGuild() await self.syncer.sync(guild, ctx) @@ -362,16 +355,15 @@ class SyncerSyncTests(unittest.TestCase): self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) @mock.patch.object(constants.Sync, "max_diff", new=3) - @helpers.async_test async def test_confirmation_result_small_diff(self): """Should always return True and the given message if the diff size is too small.""" author = helpers.MockMember() expected_message = helpers.MockMessage() - for size in (3, 2): + for size in (3, 2): # pragma: no cover with self.subTest(size=size): - self.syncer._send_prompt = helpers.AsyncMock() - self.syncer._wait_for_confirmation = helpers.AsyncMock() + self.syncer._send_prompt = mock.AsyncMock() + self.syncer._wait_for_confirmation = mock.AsyncMock() coro = self.syncer._get_confirmation_result(size, author, expected_message) result, actual_message = await coro @@ -382,7 +374,6 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._wait_for_confirmation.assert_not_called() @mock.patch.object(constants.Sync, "max_diff", new=3) - @helpers.async_test async def test_confirmation_result_large_diff(self): """Should return True if confirmed and False if _send_prompt fails or aborted.""" author = helpers.MockMember() @@ -394,10 +385,10 @@ class SyncerSyncTests(unittest.TestCase): (False, mock_message, False, "aborted"), ) - for expected_result, expected_message, confirmed, msg in subtests: + for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover with self.subTest(msg=msg): - self.syncer._send_prompt = helpers.AsyncMock(return_value=expected_message) - self.syncer._wait_for_confirmation = helpers.AsyncMock(return_value=confirmed) + self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) + self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) coro = self.syncer._get_confirmation_result(4, author) actual_result, actual_message = await coro diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 98c9afc0d..8c87c0d6b 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -18,12 +18,13 @@ class MockSyncer(helpers.CustomMockMixin, mock.MagicMock): Instances of this class will follow the specifications of `bot.cogs.sync.syncers.Syncer` instances. For more information, see the `MockGuild` docstring. """ + spec_set = Syncer def __init__(self, **kwargs) -> None: - super().__init__(spec_set=Syncer, **kwargs) + super().__init__(**kwargs) -class SyncExtensionTests(unittest.TestCase): +class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): """Tests for the sync extension.""" @staticmethod @@ -34,7 +35,7 @@ class SyncExtensionTests(unittest.TestCase): bot.add_cog.assert_called_once() -class SyncCogTestCase(unittest.TestCase): +class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): """Base class for Sync cog tests. Sets up patches for syncers.""" def setUp(self): @@ -72,13 +73,13 @@ class SyncCogTestCase(unittest.TestCase): class SyncCogTests(SyncCogTestCase): """Tests for the Sync cog.""" - @mock.patch.object(sync.Sync, "sync_guild") + @mock.patch.object(sync.Sync, "sync_guild", new_callable=mock.MagicMock) def test_sync_cog_init(self, sync_guild): """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.reset_mock() + self.bot.loop.create_task = mock.MagicMock() mock_sync_guild_coro = mock.MagicMock() sync_guild.return_value = mock_sync_guild_coro @@ -90,7 +91,6 @@ class SyncCogTests(SyncCogTestCase): sync_guild.assert_called_once_with() self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) - @helpers.async_test async def test_sync_cog_sync_guild(self): """Roles and users should be synced only if a guild is successfully retrieved.""" for guild in (helpers.MockGuild(), None): @@ -126,14 +126,12 @@ class SyncCogTests(SyncCogTestCase): json=updated_information, ) - @helpers.async_test async def test_sync_cog_patch_user(self): """A PATCH request should be sent and 404 errors ignored.""" for side_effect in (None, self.response_error(404)): with self.subTest(side_effect=side_effect): await self.patch_user_helper(side_effect) - @helpers.async_test async def test_sync_cog_patch_user_non_404(self): """A PATCH request should be sent and the error raised if it's not a 404.""" with self.assertRaises(ResponseCodeError): @@ -145,9 +143,8 @@ class SyncCogListenerTests(SyncCogTestCase): def setUp(self): super().setUp() - self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) + self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user) - @helpers.async_test async def test_sync_cog_on_guild_role_create(self): """A POST request should be sent with the new role's data.""" self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) @@ -164,7 +161,6 @@ class SyncCogListenerTests(SyncCogTestCase): self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) - @helpers.async_test async def test_sync_cog_on_guild_role_delete(self): """A DELETE request should be sent.""" self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) @@ -174,7 +170,6 @@ class SyncCogListenerTests(SyncCogTestCase): self.bot.api_client.delete.assert_called_once_with("bot/roles/99") - @helpers.async_test async def test_sync_cog_on_guild_role_update(self): """A PUT request should be sent if the colour, name, permissions, or position changes.""" self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) @@ -212,7 +207,6 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.bot.api_client.put.assert_not_called() - @helpers.async_test async def test_sync_cog_on_member_remove(self): """Member should patched to set in_guild as False.""" self.assertTrue(self.cog.on_member_remove.__cog_listener__) @@ -225,7 +219,6 @@ class SyncCogListenerTests(SyncCogTestCase): updated_information={"in_guild": False} ) - @helpers.async_test async def test_sync_cog_on_member_update_roles(self): """Members should be patched if their roles have changed.""" self.assertTrue(self.cog.on_member_update.__cog_listener__) @@ -240,7 +233,6 @@ class SyncCogListenerTests(SyncCogTestCase): data = {"roles": sorted(role.id for role in after_member.roles)} self.cog.patch_user.assert_called_once_with(after_member.id, updated_information=data) - @helpers.async_test async def test_sync_cog_on_member_update_other(self): """Members should not be patched if other attributes have changed.""" self.assertTrue(self.cog.on_member_update.__cog_listener__) @@ -262,7 +254,6 @@ class SyncCogListenerTests(SyncCogTestCase): self.cog.patch_user.assert_not_called() - @helpers.async_test async def test_sync_cog_on_user_update(self): """A user should be patched only if the name, discriminator, or avatar changes.""" self.assertTrue(self.cog.on_user_update.__cog_listener__) @@ -341,7 +332,6 @@ class SyncCogListenerTests(SyncCogTestCase): return data - @helpers.async_test async def test_sync_cog_on_member_join(self): """Should PUT user's data or POST it if the user doesn't exist.""" for side_effect in (None, self.response_error(404)): @@ -354,7 +344,6 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.bot.api_client.post.assert_not_called() - @helpers.async_test async def test_sync_cog_on_member_join_non_404(self): """ResponseCodeError should be re-raised if status code isn't a 404.""" with self.assertRaises(ResponseCodeError): @@ -366,7 +355,6 @@ class SyncCogListenerTests(SyncCogTestCase): class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): """Tests for the commands in the Sync cog.""" - @helpers.async_test async def test_sync_roles_command(self): """sync() should be called on the RoleSyncer.""" ctx = helpers.MockContext() @@ -374,7 +362,6 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) - @helpers.async_test async def test_sync_users_command(self): """sync() should be called on the UserSyncer.""" ctx = helpers.MockContext() @@ -382,7 +369,7 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) - def test_commands_require_admin(self): + async def test_commands_require_admin(self): """The sync commands should only run if the author has the administrator permission.""" cmds = ( self.cog.sync_group, @@ -392,4 +379,4 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): for cmd in cmds: with self.subTest(cmd=cmd): - self.assertHasPermissionsCheck(cmd, {"administrator": True}) + await self.assertHasPermissionsCheck(cmd, {"administrator": True}) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 14fb2577a..79eee98f4 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -18,7 +18,7 @@ def fake_role(**kwargs): return kwargs -class RoleSyncerDiffTests(unittest.TestCase): +class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Tests for determining differences between roles in the DB and roles in the Guild cache.""" def setUp(self): @@ -39,7 +39,6 @@ class RoleSyncerDiffTests(unittest.TestCase): return guild - @helpers.async_test async def test_empty_diff_for_identical_roles(self): """No differences should be found if the roles in the guild and DB are identical.""" self.bot.api_client.get.return_value = [fake_role()] @@ -50,7 +49,6 @@ class RoleSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_updated_roles(self): """Only updated roles should be added to the 'updated' set of the diff.""" updated_role = fake_role(id=41, name="new") @@ -63,7 +61,6 @@ class RoleSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_new_roles(self): """Only new roles should be added to the 'created' set of the diff.""" new_role = fake_role(id=41, name="new") @@ -76,7 +73,6 @@ class RoleSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_deleted_roles(self): """Only deleted roles should be added to the 'deleted' set of the diff.""" deleted_role = fake_role(id=61, name="deleted") @@ -89,7 +85,6 @@ class RoleSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_new_updated_and_deleted_roles(self): """When roles are added, updated, and removed, all of them are returned properly.""" new = fake_role(id=41, name="new") @@ -109,14 +104,13 @@ class RoleSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) -class RoleSyncerSyncTests(unittest.TestCase): +class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for the API requests that sync roles.""" def setUp(self): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) - @helpers.async_test async def test_sync_created_roles(self): """Only POST requests should be made with the correct payload.""" roles = [fake_role(id=111), fake_role(id=222)] @@ -132,7 +126,6 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() - @helpers.async_test async def test_sync_updated_roles(self): """Only PUT requests should be made with the correct payload.""" roles = [fake_role(id=111), fake_role(id=222)] @@ -148,7 +141,6 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot.api_client.post.assert_not_called() self.bot.api_client.delete.assert_not_called() - @helpers.async_test async def test_sync_deleted_roles(self): """Only DELETE requests should be made with the correct payload.""" roles = [fake_role(id=111), fake_role(id=222)] diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 421bf6bb6..818883012 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -17,7 +17,7 @@ def fake_user(**kwargs): return kwargs -class UserSyncerDiffTests(unittest.TestCase): +class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Tests for determining differences between users in the DB and users in the Guild cache.""" def setUp(self): @@ -42,7 +42,6 @@ class UserSyncerDiffTests(unittest.TestCase): return guild - @helpers.async_test async def test_empty_diff_for_no_users(self): """When no users are given, an empty diff should be returned.""" guild = self.get_guild() @@ -52,7 +51,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_empty_diff_for_identical_users(self): """No differences should be found if the users in the guild and DB are identical.""" self.bot.api_client.get.return_value = [fake_user()] @@ -63,7 +61,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_updated_users(self): """Only updated users should be added to the 'updated' set of the diff.""" updated_user = fake_user(id=99, name="new") @@ -76,7 +73,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_new_users(self): """Only new users should be added to the 'created' set of the diff.""" new_user = fake_user(id=99, name="new") @@ -89,7 +85,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_sets_in_guild_false_for_leaving_users(self): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" leaving_user = fake_user(id=63, in_guild=False) @@ -102,7 +97,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_new_updated_and_leaving_users(self): """When users are added, updated, and removed, all of them are returned properly.""" new_user = fake_user(id=99, name="new") @@ -117,7 +111,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_empty_diff_for_db_users_not_in_guild(self): """When the DB knows a user the guild doesn't, no difference is found.""" self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] @@ -129,14 +122,13 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) -class UserSyncerSyncTests(unittest.TestCase): +class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for the API requests that sync users.""" def setUp(self): self.bot = helpers.MockBot() self.syncer = UserSyncer(self.bot) - @helpers.async_test async def test_sync_created_users(self): """Only POST requests should be made with the correct payload.""" users = [fake_user(id=111), fake_user(id=222)] @@ -152,7 +144,6 @@ class UserSyncerSyncTests(unittest.TestCase): self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() - @helpers.async_test async def test_sync_updated_users(self): """Only PUT requests should be made with the correct payload.""" users = [fake_user(id=111), fake_user(id=222)] diff --git a/tests/helpers.py b/tests/helpers.py index 7ae7ed621..8e13f0f28 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -261,9 +261,7 @@ class MockAPIClient(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `bot.api.APIClient` instances. For more information, see the `MockGuild` docstring. """ - - def __init__(self, **kwargs) -> None: - super().__init__(spec_set=APIClient, **kwargs) + spec_set = APIClient # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` -- cgit v1.2.3 From 0de8f42c122a4bf8f0ea84ea481d2f26d718a0c7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 22:00:34 -0800 Subject: Sync tests: use autospec instead of MockSyncer Autospec supports using AsyncMocks in 3.8 so there's no need to rely on a subclass of CustomMockMixin for the async mocks. --- tests/bot/cogs/sync/test_cog.py | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 8c87c0d6b..81398c61f 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -11,19 +11,6 @@ from tests import helpers from tests.base import CommandTestCase -class MockSyncer(helpers.CustomMockMixin, mock.MagicMock): - """ - A MagicMock subclass to mock Syncer objects. - - Instances of this class will follow the specifications of `bot.cogs.sync.syncers.Syncer` - instances. For more information, see the `MockGuild` docstring. - """ - spec_set = Syncer - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - - class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): """Tests for the sync extension.""" @@ -41,16 +28,15 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = helpers.MockBot() - # These patch the type. When the type is called, a MockSyncer instanced is returned. - # MockSyncer is needed so that our custom AsyncMock is used. - # TODO: Use autospec instead in 3.8, which will automatically use AsyncMock when needed. self.role_syncer_patcher = mock.patch( "bot.cogs.sync.syncers.RoleSyncer", - new=mock.MagicMock(return_value=MockSyncer()) + autospec=Syncer, + spec_set=True ) self.user_syncer_patcher = mock.patch( "bot.cogs.sync.syncers.UserSyncer", - new=mock.MagicMock(return_value=MockSyncer()) + autospec=Syncer, + spec_set=True ) self.RoleSyncer = self.role_syncer_patcher.start() self.UserSyncer = self.user_syncer_patcher.start() -- cgit v1.2.3 From 3574eaa0c903cd8ed862b8bff896ce0a73412321 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 24 Feb 2020 10:06:09 +0100 Subject: Use MagicMock as return value for _get_diff mock The `_get_diff` method of TestSyncer class is mocked using an AsyncMock object. By default, when an AsyncMock object is called **and awaited**, it returns a child mock of the same time (another AsyncMock) according to the "the child is a like the parent" principle. This means that the _get_diff method will return an AsyncMock unless a different return_value is explicitly provided. Because of that "child is like parent" behavior, this will happen in lines 194-196 of bot.cogs.sync.syncers (annotations added by me): ``` // `diff` will be a child AsyncMock as "child is like parent" diff = await self._get_diff(guild) // `diff._asdict` will be an AsyncMock as "child is like parent" and, // after being called, it will return an unawaited coroutine object // we assign the name `diff_dict`: diff_dict = diff._asdict() // `diff_dict` is still an unawaited coroutine object meaning that it // doesn't have an `items()` method: totals = {k: len(v) for k, v in diff_dict.items() if v is not None} ``` Original, unannotated: https://github.com/python-discord/bot/blob/c81a4d401ea434e98b0a1ece51d3d10f1a3ad226/bot/cogs/sync/syncers.py#L194-L196 This will lead to the following exception when running the tests: ```py ====================================================================== ERROR: test_sync_confirmation_context_redirect (tests.bot.cogs.sync.test_base.SyncerSyncTests) (ctx=None, author=, message=None) If ctx is given, a new message should be sent and author should be ctx's author. ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/sebastiaan/pydis/repositories/bot/tests/bot/cogs/sync/test_base.py", line 348, in test_sync_confirmation_context_redirect await self.syncer.sync(guild, ctx) File "/home/sebastiaan/pydis/repositories/bot/bot/cogs/sync/syncers.py", line 196, in sync totals = {k: len(v) for k, v in diff_dict.items() if v is not None} AttributeError: 'coroutine' object has no attribute 'items' ``` The solution is to assign an explicit return value so the parent mock doesn't "guess" and return an object of its own type. I previously did that by providing a specific `_Diff` object as the return value, but I should have gone with a `MagicMock` to signify that it's not an important return value; it's just something that needs to support/mimic the API we use on it. So that's what this commit adds. --- tests/bot/cogs/sync/test_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 17aa4198b..d17a27409 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -339,8 +339,8 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): if ctx is not None: ctx.send.return_value = message - diff = _Diff({1, 2, 3}, {4, 5}, None) - self.syncer._get_diff.return_value = diff + # Make sure `_get_diff` returns a MagicMock, not an AsyncMock + self.syncer._get_diff.return_value = mock.MagicMock() self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) -- cgit v1.2.3 From 62a232b3a55a7cc983487ac165a4a9bbd6d6e3f9 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 16:56:35 +0100 Subject: Change docstring mood. --- bot/cogs/config_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py index d2bc81ba6..cc19f7423 100644 --- a/bot/cogs/config_verifier.py +++ b/bot/cogs/config_verifier.py @@ -18,7 +18,7 @@ class ConfigVerifier(Cog): async def verify_channels(self) -> None: """ - Verifies channels in config. + Verify channels. If any channels in config aren't present in server, log them in a warning. """ -- cgit v1.2.3 From 6174792c01f238e32aca5cc9222caa4feb788281 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 19:45:09 +0100 Subject: Remove unused `chunks` function and its tests. The function was only used in the since removed `Events` cog. --- bot/utils/__init__.py | 12 +----------- tests/bot/test_utils.py | 15 --------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 8184be824..3e4b15ce4 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,5 @@ from abc import ABCMeta -from typing import Any, Generator, Hashable, Iterable +from typing import Any, Hashable from discord.ext.commands import CogMeta @@ -64,13 +64,3 @@ class CaseInsensitiveDict(dict): for k in list(self.keys()): v = super(CaseInsensitiveDict, self).pop(k) self.__setitem__(k, v) - - -def chunks(iterable: Iterable, size: int) -> Generator[Any, None, None]: - """ - Generator that allows you to iterate over any indexable collection in `size`-length chunks. - - Found: https://stackoverflow.com/a/312464/4022104 - """ - for i in range(0, len(iterable), size): - yield iterable[i:i + size] diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py index 58ae2a81a..d7bcc3ba6 100644 --- a/tests/bot/test_utils.py +++ b/tests/bot/test_utils.py @@ -35,18 +35,3 @@ class CaseInsensitiveDictTests(unittest.TestCase): instance = utils.CaseInsensitiveDict() instance.update({'FOO': 'bar'}) self.assertEqual(instance['foo'], 'bar') - - -class ChunkTests(unittest.TestCase): - """Tests the `chunk` method.""" - - def test_empty_chunking(self): - """Tests chunking on an empty iterable.""" - generator = utils.chunks(iterable=[], size=5) - self.assertEqual(list(generator), []) - - def test_list_chunking(self): - """Tests chunking a non-empty list.""" - iterable = [1, 2, 3, 4, 5] - generator = utils.chunks(iterable=iterable, size=2) - self.assertEqual(list(generator), [[1, 2], [3, 4], [5]]) -- cgit v1.2.3 From b4ed7107d162d1961ae4dc03cdda282123fbb877 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 22:22:11 +0100 Subject: Do not attempt to load Reddit cog when environment variables are not provided. When environment variables weren't provided; the cog attempted to create a BasicAuth object with None as values resulting in an exception before the event loop was started and a subsequent crash. --- bot/cogs/reddit.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index aa487f18e..dce73fcf2 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -290,4 +290,7 @@ class Reddit(Cog): def setup(bot: Bot) -> None: """Load the Reddit cog.""" - bot.add_cog(Reddit(bot)) + if None not in (RedditConfig.client_id, RedditConfig.secret): + bot.add_cog(Reddit(bot)) + return + log.error("Credentials not provided, cog not loaded.") -- cgit v1.2.3 From daf50941ca6ceaa0b65d71cd9fee1ad2a67e1718 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Tue, 25 Feb 2020 14:19:00 +0100 Subject: Wait for available guild instead of bot startup. Co-authored-by: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/config_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py index cc19f7423..b43b48264 100644 --- a/bot/cogs/config_verifier.py +++ b/bot/cogs/config_verifier.py @@ -22,7 +22,7 @@ class ConfigVerifier(Cog): If any channels in config aren't present in server, log them in a warning. """ - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() server = self.bot.get_guild(constants.Guild.id) server_channel_ids = {channel.id for channel in server.channels} -- cgit v1.2.3 From d6ef05c28021db5087ce1a27a108c35c276b915f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 25 Feb 2020 14:32:32 +0100 Subject: Assign created task to a variable. Co-authored-by: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/config_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py index b43b48264..d72c6c22e 100644 --- a/bot/cogs/config_verifier.py +++ b/bot/cogs/config_verifier.py @@ -14,7 +14,7 @@ class ConfigVerifier(Cog): def __init__(self, bot: Bot): self.bot = bot - self.bot.loop.create_task(self.verify_channels()) + self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) async def verify_channels(self) -> None: """ -- cgit v1.2.3 From 3b3206471c028f87685c4c07db0c167a7066ced2 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 25 Feb 2020 12:28:10 -0500 Subject: Configure staff role & channel groupings in YAML Delete duplicate keys that were missed in the merge --- bot/constants.py | 11 +++++++---- config-default.yml | 26 ++++++++++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 285761055..b1713aa60 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -431,9 +431,12 @@ class Guild(metaclass=YAMLGetter): section = "guild" id: int + moderation_channels: List[int] + moderation_roles: List[int] modlog_blacklist: List[int] - staff_channels: List[int] reminder_whitelist: List[int] + staff_channels: List[int] + staff_roles: List[int] class Keys(metaclass=YAMLGetter): section = "keys" @@ -579,14 +582,14 @@ BOT_DIR = os.path.dirname(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) # Default role combinations -MODERATION_ROLES = Roles.moderators, Roles.admins, Roles.owners -STAFF_ROLES = Roles.helpers, Roles.moderators, Roles.admins, Roles.owners +MODERATION_ROLES = Guild.moderation_roles +STAFF_ROLES = Guild.staff_roles # Roles combinations STAFF_CHANNELS = Guild.staff_channels # Default Channel combinations -MODERATION_CHANNELS = Channels.admins, Channels.admin_spam, Channels.mod_alerts, Channels.mods, Channels.mod_spam +MODERATION_CHANNELS = Guild.moderation_channels # Bot replies diff --git a/config-default.yml b/config-default.yml index dca00500e..ab237423f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -160,7 +160,7 @@ guild: defcon: &DEFCON 464469101889454091 helpers: &HELPERS 385474242440986624 mods: &MODS 305126844661760000 - mod_alerts: 473092532147060736 + mod_alerts: &MOD_ALERTS 473092532147060736 mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 @@ -182,6 +182,13 @@ guild: - *MOD_SPAM - *ORGANISATION + moderation_channels: + - *ADMINS + - *ADMIN_SPAM + - *MOD_ALERTS + - *MODS + - *MOD_SPAM + # Modlog cog ignores events which occur in these channels modlog_blacklist: - *ADMINS @@ -195,10 +202,6 @@ guild: - *BOT_CMD - *DEV_CONTRIB - staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] - ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG] - reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] - roles: announcements: 463658397560995840 contributors: 295488872404484098 @@ -212,7 +215,7 @@ guild: # Staff admins: &ADMINS_ROLE 267628507062992896 core_developers: 587606783669829632 - helpers: 267630620367257601 + helpers: &HELPERS_ROLE 267630620367257601 moderators: &MODS_ROLE 267629731250176001 owners: &OWNERS_ROLE 267627879762755584 @@ -220,6 +223,17 @@ guild: jammers: 591786436651646989 team_leaders: 501324292341104650 + moderation_roles: + - *OWNERS_ROLE + - *ADMINS_ROLE + - *MODS_ROLE + + staff_roles: + - *OWNERS_ROLE + - *ADMINS_ROLE + - *MODS_ROLE + - *HELPERS_ROLE + webhooks: talent_pool: 569145364800602132 big_brother: 569133704568373283 -- cgit v1.2.3 From 284c1de321fea5927dafc1ac3192ad763bda3203 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 25 Feb 2020 12:47:09 -0500 Subject: Fix mismatched constant names in syncer tests --- bot/cogs/sync/syncers.py | 8 ++++---- tests/bot/cogs/sync/test_base.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 6715ad6fb..d6891168f 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -23,7 +23,7 @@ _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) class Syncer(abc.ABC): """Base class for synchronising the database with objects in the Discord cache.""" - _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developer}> " + _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) def __init__(self, bot: Bot) -> None: @@ -54,12 +54,12 @@ class Syncer(abc.ABC): # Send to core developers if it's an automatic sync. if not message: log.trace("Message not provided for confirmation; creating a new one in dev-core.") - channel = self.bot.get_channel(constants.Channels.devcore) + channel = self.bot.get_channel(constants.Channels.dev_core) if not channel: log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") try: - channel = await self.bot.fetch_channel(constants.Channels.devcore) + channel = await self.bot.fetch_channel(constants.Channels.dev_core) except HTTPException: log.exception( f"Failed to fetch channel for sending sync confirmation prompt; " @@ -93,7 +93,7 @@ class Syncer(abc.ABC): `author` of the prompt. """ # For automatic syncs, check for the core dev role instead of an exact author - has_role = any(constants.Roles.core_developer == role.id for role in user.roles) + has_role = any(constants.Roles.core_developers == role.id for role in user.roles) return ( reaction.message.id == message.id and not user.bot diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index e6a6f9688..c2e143865 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -84,7 +84,7 @@ class SyncerSendPromptTests(unittest.TestCase): mock_() await self.syncer._send_prompt() - method.assert_called_once_with(constants.Channels.devcore) + method.assert_called_once_with(constants.Channels.dev_core) @helpers.async_test async def test_send_prompt_returns_None_if_channel_fetch_fails(self): @@ -135,7 +135,7 @@ class SyncerConfirmationTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) - self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developer) + self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) @staticmethod def get_message_reaction(emoji): -- cgit v1.2.3 From 33302afd9e83cb4b5502a8b5bbe43bac450dba3f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 25 Feb 2020 22:54:03 +0100 Subject: Fix `__iter__` for classes without subsections. The previous implementation assumed the config class was a subsection, failing with a KeyError if it wasn't one. Co-authored-by: kwzrd --- bot/constants.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 3776ceb84..ebd3b3d96 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -187,8 +187,9 @@ class YAMLGetter(type): return cls.__getattr__(name) def __iter__(cls): - """Returns iterator of key: value pairs of current constants class.""" - return iter(_CONFIG_YAML[cls.section][cls.subsection].items()) + """Return generator of key: value pairs of current constants class' config values.""" + for name in cls.__annotations__: + yield name, getattr(cls, name) # Dataclasses -- cgit v1.2.3 From 1f82ed36f24f2ffbef5b3601fb6c11db28735c71 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 25 Feb 2020 22:57:52 +0100 Subject: Restyle if body to include the error instead of adding the cog. --- bot/cogs/reddit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index e93e4de0c..3278363ba 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -290,7 +290,7 @@ class Reddit(Cog): def setup(bot: Bot) -> None: """Load the Reddit cog.""" - if None not in (RedditConfig.client_id, RedditConfig.secret): - bot.add_cog(Reddit(bot)) + if None in (RedditConfig.client_id, RedditConfig.secret): + log.error("Credentials not provided, cog not loaded.") return - log.error("Credentials not provided, cog not loaded.") + bot.add_cog(Reddit(bot)) -- cgit v1.2.3 From 6a2a2b5c3da79fe0097c98a04e435e493c73223d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 00:29:52 +0100 Subject: Check for empty strings alongside None before loading cog. Docker fetches values from the .env itself and defaults to "" instead of None, needing to do invalid access token requests before unloading itself. --- bot/cogs/reddit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 3278363ba..7cb340145 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -290,7 +290,8 @@ class Reddit(Cog): def setup(bot: Bot) -> None: """Load the Reddit cog.""" - if None in (RedditConfig.client_id, RedditConfig.secret): + invalid_values = "", None + if any(value in (RedditConfig.secret, RedditConfig.client_id) for value in invalid_values): log.error("Credentials not provided, cog not loaded.") return bot.add_cog(Reddit(bot)) -- cgit v1.2.3 From e8e2fa9ee8f607bb6593b7c8325446dc074a972d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 00:33:29 +0100 Subject: Make sure token exists before checking its expiration. Without the check and an invalid token, an AttributeError is raised; blocking the relevant ClientError from being raised in `get_access_token`. --- bot/cogs/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 7cb340145..982c0cbe6 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -43,7 +43,7 @@ class Reddit(Cog): def cog_unload(self) -> None: """Stop the loop task and revoke the access token when the cog is unloaded.""" self.auto_poster_loop.cancel() - if self.access_token.expires_at < datetime.utcnow(): + if self.access_token and self.access_token.expires_at < datetime.utcnow(): self.revoke_access_token() async def init_reddit_ready(self) -> None: -- cgit v1.2.3 From df87aba432db50eb480ba8b2f42b1a64147909d9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Feb 2020 20:15:58 -0800 Subject: Moderation: use asyncio.shield to prevent self-cancellation The shield exists to be used for exactly this purpose so its a better fit than create_task. --- bot/cogs/moderation/scheduler.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 162159af8..93afd9f9f 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -1,3 +1,4 @@ +import asyncio import logging import textwrap import typing as t @@ -427,6 +428,6 @@ class InfractionScheduler(Scheduler): expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) await time.wait_until(expiry) - # Because deactivate_infraction() explicitly cancels this scheduled task, it runs in - # a separate task to avoid prematurely cancelling itself. - self.bot.loop.create_task(self.deactivate_infraction(infraction)) + # Because deactivate_infraction() explicitly cancels this scheduled task, it is shielded + # to avoid prematurely cancelling itself. + await asyncio.shield(self.deactivate_infraction(infraction)) -- cgit v1.2.3 From b1f8f4779738be35e1339d6c07e317ef08009467 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Feb 2020 21:20:20 -0800 Subject: Scheduler: improve cancel_task's docstring * Use imperative mood for docstring * Explain the purpose of the parameter in the docstring * Make log message after cog name lowercase --- bot/utils/scheduling.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 0d66952eb..cb28648db 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -52,19 +52,19 @@ class Scheduler(metaclass=CogABCMeta): log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.") def cancel_task(self, task_id: str) -> None: - """Un-schedules a task.""" + """Unschedule the task identified by `task_id`.""" log.trace(f"{self.cog_name}: cancelling task #{task_id}...") - task = self._scheduled_tasks.get(task_id) - if task is None: - log.warning(f"{self.cog_name}: Failed to unschedule {task_id} (no task found).") + if not task: + log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).") return task.cancel() - log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") del self._scheduled_tasks[task_id] + log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") + def _task_done_callback(self, task_id: str, task: asyncio.Task) -> None: """ Unschedule the task and raise its exception if one exists. -- cgit v1.2.3 From 4a11bf22cc9e894271b896eb9fca0c3cff085766 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Feb 2020 22:22:01 -0800 Subject: Scheduler: only delete the task in the done callback if tasks are same To prevent a deletion of task rescheduled with the same ID, the callback checks that the stored task is the same as the done task being handled. * Only delete the task; it doesn't need to be cancelled because the it is already done * Revise the callback's docstring to explain the new behaviour * Rename `task` parameter to `done_task` --- bot/utils/scheduling.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index cb28648db..58bb32e5d 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -65,24 +65,33 @@ class Scheduler(metaclass=CogABCMeta): log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") - def _task_done_callback(self, task_id: str, task: asyncio.Task) -> None: + def _task_done_callback(self, task_id: str, done_task: asyncio.Task) -> None: """ - Unschedule the task and raise its exception if one exists. + Delete the task and raise its exception if one exists. - If the task was cancelled, the CancelledError is retrieved and suppressed. In this case, - the task is already assumed to have been unscheduled. + If `done_task` and the task associated with `task_id` are different, then the latter + will not be deleted. In this case, a new task was likely rescheduled with the same ID. """ - log.trace(f"{self.cog_name}: performing done callback for task #{task_id} {id(task)}") + log.trace(f"{self.cog_name}: performing done callback for task #{task_id} {id(done_task)}.") - if task.cancelled(): - with contextlib.suppress(asyncio.CancelledError): - task.exception() + scheduled_task = self._scheduled_tasks.get(task_id) + + if scheduled_task and done_task is scheduled_task: + # A task for the ID exists and its the same as the done task. + # Since this is the done callback, the task is already done so no need to cancel it. + log.trace(f"{self.cog_name}: deleting task #{task_id} {id(done_task)}.") + del self._scheduled_tasks[task_id] + elif scheduled_task: + # A new task was likely rescheduled with the same ID. + log.debug( + f"{self.cog_name}: the scheduled task #{task_id} {id(scheduled_task)} " + f"and the done task {id(done_task)} differ." + ) else: - # Check if it exists to avoid logging a warning. - if task_id in self._scheduled_tasks: - # Only cancel if the task is not cancelled to avoid a race condition when a new - # task is scheduled using the same ID. Reminders do this when re-scheduling after - # editing. - self.cancel_task(task_id) - - task.exception() + log.warning( + f"{self.cog_name}: task #{task_id} not found while handling task {id(done_task)}! " + f"A task somehow got unscheduled improperly (i.e. deleted but not cancelled)." + ) + + with contextlib.suppress(asyncio.CancelledError): + done_task.exception() -- cgit v1.2.3 From 47b645a2cd2622709c57158d788554544579d870 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Feb 2020 22:23:07 -0800 Subject: Scheduler: properly raise task's exception the done callback Task.exception() only returns the exception. It still needs to be explicitly raised. --- bot/utils/scheduling.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 58bb32e5d..742133f02 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -94,4 +94,7 @@ class Scheduler(metaclass=CogABCMeta): ) with contextlib.suppress(asyncio.CancelledError): - done_task.exception() + exception = done_task.exception() + # Raise the exception if one exists. + if exception: + raise exception -- cgit v1.2.3 From e173cd2af20b546230ed467f26286ee167df55cd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Feb 2020 22:34:35 -0800 Subject: Scheduler: only send warning in callback if task isn't cancelled If a task is cancelled it is assumed it was done via cancel_task. That method deletes the task after cancelling so the warning isn't relevant. --- bot/utils/scheduling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 742133f02..9371dcdb7 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -87,7 +87,7 @@ class Scheduler(metaclass=CogABCMeta): f"{self.cog_name}: the scheduled task #{task_id} {id(scheduled_task)} " f"and the done task {id(done_task)} differ." ) - else: + elif not done_task.cancelled(): log.warning( f"{self.cog_name}: task #{task_id} not found while handling task {id(done_task)}! " f"A task somehow got unscheduled improperly (i.e. deleted but not cancelled)." -- cgit v1.2.3 From d91f821fccfa61f324489baff5debbebb3adbb51 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 08:42:30 +0100 Subject: Create task for `revoke_access_token` when unloading cog to ensure it's executed. --- bot/cogs/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 982c0cbe6..6fe7f820b 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -44,7 +44,7 @@ class Reddit(Cog): """Stop the loop task and revoke the access token when the cog is unloaded.""" self.auto_poster_loop.cancel() if self.access_token and self.access_token.expires_at < datetime.utcnow(): - self.revoke_access_token() + asyncio.create_task(self.revoke_access_token()) async def init_reddit_ready(self) -> None: """Sets the reddit webhook when the cog is loaded.""" -- cgit v1.2.3 From 5a0a04ea4fb4a9aa17e7f807e72f2bcd5e3e6349 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 08:43:06 +0100 Subject: Specify the logged time is in UTC. --- bot/cogs/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 6fe7f820b..22e5c2a9c 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -83,7 +83,7 @@ class Reddit(Cog): expires_at=datetime.utcnow() + timedelta(seconds=expiration) ) - log.debug(f"New token acquired; expires on {self.access_token.expires_at}") + log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") return else: log.debug( -- cgit v1.2.3 From f87a53ff442bc10cd2cba87943e40c531e0ce9ba Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 10:43:38 +0100 Subject: Check for falsy values instead of ``""` and `None` explicitly. After the change to also check empty strings to avoid unucessary requests, it is no longer necessary to do an explicit value check, as the only values that can come from the .env file are `None` and strings Co-authored-by: Karlis S <45097959+ks129@users.noreply.github.com> --- bot/cogs/reddit.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 22e5c2a9c..6d03928e0 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -290,8 +290,7 @@ class Reddit(Cog): def setup(bot: Bot) -> None: """Load the Reddit cog.""" - invalid_values = "", None - if any(value in (RedditConfig.secret, RedditConfig.client_id) for value in invalid_values): + if not RedditConfig.secret or not RedditConfig.client_id: log.error("Credentials not provided, cog not loaded.") return bot.add_cog(Reddit(bot)) -- cgit v1.2.3 From c2d695fe196c49d3dfcae1186c1dafe13ba98e88 Mon Sep 17 00:00:00 2001 From: "Karlis. S" Date: Tue, 25 Feb 2020 20:01:15 +0200 Subject: Added to AntiMalware staff ignore check. --- bot/cogs/antimalware.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 28e3e5d96..d2ff9f79c 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -4,7 +4,7 @@ from discord import Embed, Message, NotFound from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import AntiMalware as AntiMalwareConfig, Channels, URLs +from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES, URLs log = logging.getLogger(__name__) @@ -21,6 +21,10 @@ class AntiMalware(Cog): if not message.attachments: return + # Check if user is staff, if is, return + if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): + return + embed = Embed() for attachment in message.attachments: filename = attachment.filename.lower() -- cgit v1.2.3 From f779f60b5376872043eda8c25712f0dd2a451a78 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 18:20:19 +0100 Subject: Fix comparison operator when checking token expiration. With `<` the check only went through when the token was already expired, making revoking redundant; and didn't go through when the token still had some time before expiration. --- bot/cogs/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 6d03928e0..5a7fa100f 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -43,7 +43,7 @@ class Reddit(Cog): def cog_unload(self) -> None: """Stop the loop task and revoke the access token when the cog is unloaded.""" self.auto_poster_loop.cancel() - if self.access_token and self.access_token.expires_at < datetime.utcnow(): + if self.access_token and self.access_token.expires_at > datetime.utcnow(): asyncio.create_task(self.revoke_access_token()) async def init_reddit_ready(self) -> None: -- cgit v1.2.3 From b628c5b9a054af22851aa099f0656ed465472456 Mon Sep 17 00:00:00 2001 From: ks123 Date: Wed, 26 Feb 2020 19:34:01 +0200 Subject: Added DMs ignoring to antimalware check --- bot/cogs/antimalware.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index d2ff9f79c..957c458c0 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -18,7 +18,8 @@ class AntiMalware(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" - if not message.attachments: + # Return when message don't have attachment and don't moderate DMs + if not message.attachments or not message.guild: return # Check if user is staff, if is, return -- cgit v1.2.3 From 68f97584d0e472857f07f8421001e007d5983164 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 19:00:52 +0100 Subject: Make sure tag name contains at least one letter. With only ascii and numbers being allowed to go through, possible values still included things like `$()` which don't match anything in `REGEX_NON_ALPHABET` from tags.py resulting in an error. --- bot/converters.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index cca57a02d..d73ab73f1 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -175,6 +175,12 @@ class TagNameConverter(Converter): "Rejecting the request.") raise BadArgument("Are you insane? That's way too long!") + # The tag name is ascii but does not contain any letters. + elif not any(character.isalpha() for character in tag_name): + log.warning(f"{ctx.author} tried to request a tag name without letters. " + "Rejecting the request.") + raise BadArgument("Tag names must contain at least one letter.") + return tag_name -- cgit v1.2.3 From b4a52aded317572d51a0747ed8d74b3fc84c9428 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 19:13:48 +0100 Subject: Pass error handler tag fallback through TagNameConverter. The tag fallback didn't convert tags, resulting in possible invalid tag names being passed to the `tags_get_command`. This makes sure they're valid and ignores the risen exception if they are not. --- bot/cogs/error_handler.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 0abb7e521..3486a746c 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -20,6 +20,7 @@ from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels +from bot.converters import TagNameConverter from bot.decorators import InChannelCheckFailure log = logging.getLogger(__name__) @@ -88,8 +89,11 @@ class ErrorHandler(Cog): return # Return to not raise the exception - with contextlib.suppress(ResponseCodeError): - await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + with contextlib.suppress(BadArgument, ResponseCodeError): + await ctx.invoke( + tags_get_command, + tag_name=await TagNameConverter.convert(ctx, ctx.invoked_with) + ) return elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") -- cgit v1.2.3 From 432311ce720a1aea23c3ed7b422615a2e304b070 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 19:22:28 +0100 Subject: Remove number check on tags. This case is already covered by checking if at least one letter is included. --- bot/converters.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index d73ab73f1..745ce5b5d 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -141,14 +141,6 @@ class TagNameConverter(Converter): @staticmethod async def convert(ctx: Context, tag_name: str) -> str: """Lowercase & strip whitespace from proposed tag_name & ensure it's valid.""" - def is_number(value: str) -> bool: - """Check to see if the input string is numeric.""" - try: - float(value) - except ValueError: - return False - return True - tag_name = tag_name.lower().strip() # The tag name has at least one invalid character. @@ -163,12 +155,6 @@ class TagNameConverter(Converter): "Rejecting the request.") raise BadArgument("Tag names should not be empty, or filled with whitespace.") - # The tag name is a number of some kind, we don't allow that. - elif is_number(tag_name): - log.warning(f"{ctx.author} tried to create a tag with a digit as its name. " - "Rejecting the request.") - raise BadArgument("Tag names can't be numbers.") - # The tag name is longer than 127 characters. elif len(tag_name) > 127: log.warning(f"{ctx.author} tried to request a tag name with over 127 characters. " -- cgit v1.2.3 From c0d2f51f4e4a57da23f1387e8cf6b1a9e8c02e73 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 19:24:52 +0100 Subject: Adjust tests for new converter behavior. --- tests/bot/test_converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index b2b78d9dd..1e5ca62ae 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -68,7 +68,7 @@ class ConverterTests(unittest.TestCase): ('๐Ÿ‘‹', "Don't be ridiculous, you can't use that character!"), ('', "Tag names should not be empty, or filled with whitespace."), (' ', "Tag names should not be empty, or filled with whitespace."), - ('42', "Tag names can't be numbers."), + ('42', "Tag names must contain at least one letter."), ('x' * 128, "Are you insane? That's way too long!"), ) -- cgit v1.2.3 From 43597788aef30381924efe8298aaa6c15f8d33f9 Mon Sep 17 00:00:00 2001 From: Joseph Date: Wed, 26 Feb 2020 19:32:34 +0000 Subject: Disable TRACE logging for Sentry breadcrumbs. --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index 490163739..a3f1855b5 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -10,7 +10,7 @@ from bot.bot import Bot from bot.constants import Bot as BotConfig, DEBUG_MODE sentry_logging = LoggingIntegration( - level=logging.TRACE, + level=logging.DEBUG, event_level=logging.WARNING ) -- cgit v1.2.3 From 5ae3949899bf8f5d637f7f5025311342012f6db6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 20:36:36 +0100 Subject: Remove logging from tag converters. --- bot/converters.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 745ce5b5d..1945e1da3 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -145,26 +145,18 @@ class TagNameConverter(Converter): # The tag name has at least one invalid character. if ascii(tag_name)[1:-1] != tag_name: - log.warning(f"{ctx.author} tried to put an invalid character in a tag name. " - "Rejecting the request.") raise BadArgument("Don't be ridiculous, you can't use that character!") # The tag name is either empty, or consists of nothing but whitespace. elif not tag_name: - log.warning(f"{ctx.author} tried to create a tag with a name consisting only of whitespace. " - "Rejecting the request.") raise BadArgument("Tag names should not be empty, or filled with whitespace.") # The tag name is longer than 127 characters. elif len(tag_name) > 127: - log.warning(f"{ctx.author} tried to request a tag name with over 127 characters. " - "Rejecting the request.") raise BadArgument("Are you insane? That's way too long!") # The tag name is ascii but does not contain any letters. elif not any(character.isalpha() for character in tag_name): - log.warning(f"{ctx.author} tried to request a tag name without letters. " - "Rejecting the request.") raise BadArgument("Tag names must contain at least one letter.") return tag_name @@ -184,8 +176,6 @@ class TagContentConverter(Converter): # The tag contents should not be empty, or filled with whitespace. if not tag_content: - log.warning(f"{ctx.author} tried to create a tag containing only whitespace. " - "Rejecting the request.") raise BadArgument("Tag contents should not be empty, or filled with whitespace.") return tag_content -- cgit v1.2.3 From 97b07a8be526b62db0b1b072b4d9773bff7a8db1 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 21:58:08 +0100 Subject: Log invalid tag names in the error handler tag fallback. --- bot/cogs/error_handler.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 3486a746c..fff1f8c9f 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -89,12 +89,20 @@ class ErrorHandler(Cog): return # Return to not raise the exception - with contextlib.suppress(BadArgument, ResponseCodeError): - await ctx.invoke( - tags_get_command, - tag_name=await TagNameConverter.convert(ctx, ctx.invoked_with) + try: + tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with) + except BadArgument: + log.debug( + f"{ctx.author} tried to use an invalid command " + f"and the fallback tag failed validation in TagNameConverter." ) - return + else: + with contextlib.suppress(ResponseCodeError): + await ctx.invoke( + tags_get_command, + tag_name=tag_name + ) + return elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) -- cgit v1.2.3 From ded5c543d6b7abcb9789cd3c8f097a5649270c75 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 26 Feb 2020 16:04:17 -0500 Subject: Add clarifying comment to role checking logic implementation --- bot/cogs/antimalware.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 957c458c0..9e9e81364 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -23,6 +23,7 @@ class AntiMalware(Cog): return # Check if user is staff, if is, return + # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): return -- cgit v1.2.3 From f87f7559db8b352490324a535fb77e88f2f68b41 Mon Sep 17 00:00:00 2001 From: Matteo Date: Thu, 27 Feb 2020 11:37:02 +0100 Subject: Split the eval command procedure into two functions. Two functions were created: send_eval and continue_eval, in order to facilitate testing. The corresponding tests are also changed in this commit. --- bot/cogs/snekbox.py | 112 +++++++++++++++++------------- tests/bot/cogs/test_snekbox.py | 150 ++++++++++++++++++++++------------------- 2 files changed, 148 insertions(+), 114 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index efa4696b5..25b2455e8 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -179,6 +179,68 @@ class Snekbox(Cog): return output, paste_link + async def send_eval(self, ctx: Context, code: str) -> Message: + """ + Evaluate code, format it, and send the output to the corresponding channel. + + Return the bot response. + """ + async with ctx.typing(): + results = await self.post_eval(code) + msg, error = self.get_results_message(results) + + if error: + output, paste_link = error, None + else: + output, paste_link = await self.format_output(results["stdout"]) + + icon = self.get_status_emoji(results) + msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```" + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + + response = await ctx.send(msg) + self.bot.loop.create_task( + wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + ) + + log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") + return response + + async def continue_eval(self, ctx: Context, response: Message) -> Tuple[bool, Optional[str]]: + """ + Check if the eval session should continue. + + First item of the returned tuple is if the eval session should continue, + the second is the new code to evaluate. + """ + _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) + _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) + + try: + _, new_message = await self.bot.wait_for( + 'message_edit', + check=_predicate_eval_message_edit, + timeout=10 + ) + await ctx.message.add_reaction('๐Ÿ”') + await self.bot.wait_for( + 'reaction_add', + check=_predicate_emoji_reaction, + timeout=10 + ) + + code = new_message.content.split(' ', maxsplit=1)[1] + await ctx.message.clear_reactions() + with contextlib.suppress(HTTPException): + await response.delete() + + except asyncio.TimeoutError: + await ctx.message.clear_reactions() + return False, None + + return True, code + @command(name="eval", aliases=("e",)) @guild_only() @in_channel(Channels.bot, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) @@ -203,58 +265,18 @@ class Snekbox(Cog): log.info(f"Received code from {ctx.author} for evaluation:\n{code}") - _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) - _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) - while True: self.jobs[ctx.author.id] = datetime.datetime.now() code = self.prepare_input(code) - try: - async with ctx.typing(): - results = await self.post_eval(code) - msg, error = self.get_results_message(results) - - if error: - output, paste_link = error, None - else: - output, paste_link = await self.format_output(results["stdout"]) - - icon = self.get_status_emoji(results) - msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```" - if paste_link: - msg = f"{msg}\nFull output: {paste_link}" - - response = await ctx.send(msg) - self.bot.loop.create_task( - wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) - ) - - log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") + response = await self.send_eval(ctx, code) finally: del self.jobs[ctx.author.id] - try: - _, new_message = await self.bot.wait_for( - 'message_edit', - check=_predicate_eval_message_edit, - timeout=10 - ) - await ctx.message.add_reaction('๐Ÿ”') - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=10 - ) - - log.info(f"Re-evaluating message {ctx.message.id}") - code = new_message.content.split(' ', maxsplit=1)[1] - await ctx.message.clear_reactions() - with contextlib.suppress(HTTPException): - await response.delete() - except asyncio.TimeoutError: - await ctx.message.clear_reactions() - return + continue_eval, code = await self.continue_eval(ctx, response) + if not continue_eval: + break + log.info(f"Re-evaluating message {ctx.message.id}") def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 112c923c8..c1c0f8d47 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -1,6 +1,7 @@ import asyncio import logging import unittest +from functools import partial from unittest.mock import MagicMock, Mock, call, patch from bot.cogs import snekbox @@ -175,28 +176,33 @@ class SnekboxTests(unittest.TestCase): async def test_eval_command_evaluate_once(self): """Test the eval command procedure.""" ctx = MockContext() - ctx.message = MockMessage() - ctx.send = AsyncMock() - ctx.author.mention = '@LemonLemonishBeard#0042' - ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) - self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) - self.cog.get_status_emoji = MagicMock(return_value=':yay!:') - self.cog.format_output = AsyncMock(return_value=('[No output]', None)) - self.bot.wait_for.side_effect = asyncio.TimeoutError + response = MockMessage() + self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') + self.cog.send_eval = AsyncMock(return_value=response) + self.cog.continue_eval = AsyncMock(return_value=(False, None)) await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + self.cog.prepare_input.assert_called_once_with('MyAwesomeCode') + self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode') + self.cog.continue_eval.assert_called_once_with(ctx, response) - ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```' - ) - self.cog.post_eval.assert_called_once_with('MyAwesomeCode') - self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) - self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) - self.cog.format_output.assert_called_once_with('') + @async_test + async def test_eval_command_evaluate_twice(self): + """Test the eval and re-eval command procedure.""" + ctx = MockContext() + response = MockMessage() + self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') + self.cog.send_eval = AsyncMock(return_value=response) + self.cog.continue_eval = AsyncMock() + self.cog.continue_eval.side_effect = ((True, 'MyAwesomeCode-2'), (False, None)) + + await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2')) + self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode') + self.cog.continue_eval.assert_called_with(ctx, response) @async_test - async def test_eval_command_reject_two_eval(self): + async def test_eval_command_reject_two_eval_at_the_same_time(self): """Test if the eval command rejects an eval if the author already have a running eval.""" ctx = MockContext() ctx.author.id = 42 @@ -217,92 +223,98 @@ class SnekboxTests(unittest.TestCase): ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") @async_test - async def test_eval_command_return_error(self): - """Test the eval command error handling.""" + async def test_send_eval(self): + """Test the send_eval function.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127}) - self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Error occurred')) - self.cog.get_status_emoji = MagicMock(return_value=':nope!:') - self.cog.format_output = AsyncMock() - self.bot.wait_for.side_effect = asyncio.TimeoutError - - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) + self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) + self.cog.get_status_emoji = MagicMock(return_value=':yay!:') + self.cog.format_output = AsyncMock(return_value=('[No output]', None)) + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nError occurred\n```' + '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') - self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) - self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) - self.cog.format_output.assert_not_called() + self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) + self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) + self.cog.format_output.assert_called_once_with('') @async_test - async def test_eval_command_with_paste_link(self): - """Test the eval command procedure with the use of a paste link.""" + async def test_send_eval_with_paste_link(self): + """Test the send_eval function with a too long output that generate a paste link.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': 'SuperLongBeard', 'returncode': 0}) + self.cog.post_eval = AsyncMock(return_value={'stdout': 'Way too long beard', 'returncode': 0}) self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) self.cog.get_status_emoji = MagicMock(return_value=':yay!:') - self.cog.format_output = AsyncMock(return_value=('Truncated - too long beard', 'https://testificate.com/')) - self.bot.wait_for.side_effect = asyncio.TimeoutError - - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com')) + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n' - 'Truncated - too long beard\n```\nFull output: https://testificate.com/' + '@LemonLemonishBeard#0042 :yay!: Return code 0.' + '\n\n```py\nWay too long beard\n```\nFull output: lookatmybeard.com' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') - self.cog.get_status_emoji.assert_called_once_with({'stdout': 'SuperLongBeard', 'returncode': 0}) - self.cog.get_results_message.assert_called_once_with({'stdout': 'SuperLongBeard', 'returncode': 0}) - self.cog.format_output.assert_called_with('SuperLongBeard') + self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) + self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) + self.cog.format_output.assert_called_once_with('Way too long beard') @async_test - async def test_eval_command_evaluate_twice(self): - """Test the eval command re-evaluation procedure.""" + async def test_send_eval_with_non_zero_eval(self): + """Test the send_eval function with a code returning a non-zero code.""" ctx = MockContext() ctx.message = MockMessage() - ctx.message.content = '!e MyAwesomeCode' - updated_msg = MockMessage() - updated_msg .content = '!e MyAwesomeCode-2' - response_msg = MockMessage() - response_msg.delete = AsyncMock() - ctx.send = AsyncMock(return_value=response_msg) + ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) - self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) - self.cog.get_status_emoji = MagicMock(return_value=':yay!:') - self.cog.format_output = AsyncMock(return_value=('[No output]', None)) - self.bot.wait_for.side_effect = ((None, updated_msg), None, asyncio.TimeoutError) - - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') - - self.cog.post_eval.assert_has_calls((call('MyAwesomeCode'), call('MyAwesomeCode-2'))) + self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127}) + self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Beard got stuck in the eval')) + self.cog.get_status_emoji = MagicMock(return_value=':nope!:') + self.cog.format_output = AsyncMock() # This function isn't called - # Multiplied by 2 because we expect it to be called twice - ctx.send.assert_has_calls( - [call('@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```')] * 2 + await self.cog.send_eval(ctx, 'MyAwesomeCode') + ctx.send.assert_called_once_with( + '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nBeard got stuck in the eval\n```' ) - self.cog.get_status_emoji.assert_has_calls([call({'stdout': '', 'returncode': 0})] * 2) - self.cog.get_results_message.assert_has_calls([call({'stdout': '', 'returncode': 0})] * 2) - self.cog.format_output.assert_has_calls([call('')] * 2) + self.cog.post_eval.assert_called_once_with('MyAwesomeCode') + self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) + self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) + self.cog.format_output.assert_not_called() + @async_test + async def test_continue_eval_does_continue(self): + """Test that the continue_eval function does continue if required conditions are met.""" + ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock())) + response = MockMessage(delete=AsyncMock()) + new_msg = MockMessage(content='!e NewCode') + self.bot.wait_for.side_effect = ((None, new_msg), None) + + actual = await self.cog.continue_eval(ctx, response) + self.assertEqual(actual, (True, 'NewCode')) self.bot.wait_for.has_calls( - call('message_edit', check=snekbox.predicate_eval_message_edit, timeout=10), - call('reaction_add', check=snekbox.predicate_eval_emoji_reaction, timeout=10) + call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), + call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) ) ctx.message.add_reaction.assert_called_once_with('๐Ÿ”') - ctx.message.clear_reactions.assert_called() - response_msg.delete.assert_called_once() + ctx.message.clear_reactions.assert_called_once() + response.delete.assert_called_once() + + @async_test + async def test_continue_eval_does_not_continue(self): + ctx = MockContext(message=MockMessage(clear_reactions=AsyncMock())) + self.bot.wait_for.side_effect = asyncio.TimeoutError + + actual = await self.cog.continue_eval(ctx, MockMessage()) + self.assertEqual(actual, (False, None)) + ctx.message.clear_reactions.assert_called_once() def test_predicate_eval_message_edit(self): """Test the predicate_eval_message_edit function.""" -- cgit v1.2.3 From afc74faadd5fb4d3fd3003bed9f2a1f241c0dc58 Mon Sep 17 00:00:00 2001 From: Matteo Date: Thu, 27 Feb 2020 11:47:01 +0100 Subject: Use unicode code point instead of literal for the snekbox re-eval emoji Unicode literals aren't really safe compared to code points --- bot/cogs/snekbox.py | 8 +++++--- tests/bot/cogs/test_snekbox.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 25b2455e8..52d830fa8 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -42,6 +42,8 @@ EVAL_ROLES = (Roles.helpers, Roles.moderator, Roles.admin, Roles.owner, Roles.ro SIGKILL = 9 +REEVAL_EMOJI = '\U0001f501' # :repeat: + class Snekbox(Cog): """Safe evaluation of Python code using Snekbox.""" @@ -223,7 +225,7 @@ class Snekbox(Cog): check=_predicate_eval_message_edit, timeout=10 ) - await ctx.message.add_reaction('๐Ÿ”') + await ctx.message.add_reaction(REEVAL_EMOJI) await self.bot.wait_for( 'reaction_add', check=_predicate_emoji_reaction, @@ -285,8 +287,8 @@ def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: - """Return True if the reaction ๐Ÿ” was added by the context message author on this message.""" - return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == '๐Ÿ”' + """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message.""" + return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI def setup(bot: Bot) -> None: diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index c1c0f8d47..e7a1e3362 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -303,7 +303,7 @@ class SnekboxTests(unittest.TestCase): call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) ) - ctx.message.add_reaction.assert_called_once_with('๐Ÿ”') + ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) ctx.message.clear_reactions.assert_called_once() response.delete.assert_called_once() @@ -336,12 +336,12 @@ class SnekboxTests(unittest.TestCase): def test_predicate_eval_emoji_reaction(self): """Test the predicate_eval_emoji_reaction function.""" valid_reaction = MockReaction(message=MockMessage(id=1)) - valid_reaction.__str__.return_value = '๐Ÿ”' + valid_reaction.__str__.return_value = snekbox.REEVAL_EMOJI valid_ctx = MockContext(message=MockMessage(id=1), author=MockUser(id=2)) valid_user = MockUser(id=2) invalid_reaction_id = MockReaction(message=MockMessage(id=42)) - invalid_reaction_id.__str__.return_value = '๐Ÿ”' + invalid_reaction_id.__str__.return_value = snekbox.REEVAL_EMOJI invalid_user_id = MockUser(id=42) invalid_reaction_str = MockReaction(message=MockMessage(id=1)) invalid_reaction_str.__str__.return_value = ':longbeard:' -- cgit v1.2.3 From 671052ca7862fd75c38e5f5162ce0dc4ded8531b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 08:39:50 -0800 Subject: Moderation: fix task cancellation for permanent infraction when editing A task should not be cancelled if an infraction is permanent because tasks don't exist for permanent infractions. Fixes BOT-1V --- bot/cogs/moderation/management.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index f2964cd78..f74089056 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -129,7 +129,9 @@ class ModManagement(commands.Cog): # Re-schedule infraction if the expiration has been updated if 'expires_at' in request_data: - self.infractions_cog.cancel_task(new_infraction['id']) + # A scheduled task should only exist if the old infraction wasn't permanent + if old_infraction['expires_at']: + self.infractions_cog.cancel_task(new_infraction['id']) # If the infraction was not marked as permanent, schedule a new expiration task if request_data['expires_at']: -- cgit v1.2.3 From 848a613dee4bcbb00226fb98fc6501403dc0d7c7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 27 Feb 2020 18:53:44 +0100 Subject: Remove unnecessary newlines from call. --- bot/cogs/error_handler.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index fff1f8c9f..0d4604430 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -98,10 +98,7 @@ class ErrorHandler(Cog): ) else: with contextlib.suppress(ResponseCodeError): - await ctx.invoke( - tags_get_command, - tag_name=tag_name - ) + await ctx.invoke(tags_get_command, tag_name=tag_name) return elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") -- cgit v1.2.3 From 4096ef526aba41ab3fd83be16ef3b5554419d524 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 19:43:45 -0800 Subject: Scheduler: log the exception instead of raising Logging it ourselves has a cleaner traceback and gives more control over the output, such as including the task ID. --- bot/utils/scheduling.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 9371dcdb7..1eae817c1 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -95,6 +95,9 @@ class Scheduler(metaclass=CogABCMeta): with contextlib.suppress(asyncio.CancelledError): exception = done_task.exception() - # Raise the exception if one exists. + # Log the exception if one exists. if exception: - raise exception + log.error( + f"{self.cog_name}: error in task #{task_id} {id(scheduled_task)}!", + exc_info=exception + ) -- cgit v1.2.3 From b62e15f835ff4b6c808a9b571919bcfa479d004b Mon Sep 17 00:00:00 2001 From: Matteo Date: Fri, 28 Feb 2020 09:41:12 +0100 Subject: Return only the new code in continue_eval and check for truthiness instead --- bot/cogs/snekbox.py | 13 ++++++------- tests/bot/cogs/test_snekbox.py | 8 ++++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 52d830fa8..381b309e0 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -209,12 +209,11 @@ class Snekbox(Cog): log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") return response - async def continue_eval(self, ctx: Context, response: Message) -> Tuple[bool, Optional[str]]: + async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]: """ Check if the eval session should continue. - First item of the returned tuple is if the eval session should continue, - the second is the new code to evaluate. + Return the new code to evaluate or None if the eval session should be terminated. """ _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) @@ -239,9 +238,9 @@ class Snekbox(Cog): except asyncio.TimeoutError: await ctx.message.clear_reactions() - return False, None + return None - return True, code + return code @command(name="eval", aliases=("e",)) @guild_only() @@ -275,8 +274,8 @@ class Snekbox(Cog): finally: del self.jobs[ctx.author.id] - continue_eval, code = await self.continue_eval(ctx, response) - if not continue_eval: + code = await self.continue_eval(ctx, response) + if not code: break log.info(f"Re-evaluating message {ctx.message.id}") diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index e7a1e3362..985bc66a1 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -179,7 +179,7 @@ class SnekboxTests(unittest.TestCase): response = MockMessage() self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') self.cog.send_eval = AsyncMock(return_value=response) - self.cog.continue_eval = AsyncMock(return_value=(False, None)) + self.cog.continue_eval = AsyncMock(return_value=None) await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') self.cog.prepare_input.assert_called_once_with('MyAwesomeCode') @@ -194,7 +194,7 @@ class SnekboxTests(unittest.TestCase): self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') self.cog.send_eval = AsyncMock(return_value=response) self.cog.continue_eval = AsyncMock() - self.cog.continue_eval.side_effect = ((True, 'MyAwesomeCode-2'), (False, None)) + self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None) await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2')) @@ -298,7 +298,7 @@ class SnekboxTests(unittest.TestCase): self.bot.wait_for.side_effect = ((None, new_msg), None) actual = await self.cog.continue_eval(ctx, response) - self.assertEqual(actual, (True, 'NewCode')) + self.assertEqual(actual, 'NewCode') self.bot.wait_for.has_calls( call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) @@ -313,7 +313,7 @@ class SnekboxTests(unittest.TestCase): self.bot.wait_for.side_effect = asyncio.TimeoutError actual = await self.cog.continue_eval(ctx, MockMessage()) - self.assertEqual(actual, (False, None)) + self.assertEqual(actual, None) ctx.message.clear_reactions.assert_called_once() def test_predicate_eval_message_edit(self): -- cgit v1.2.3 From 77ee577598ae0ed9d49a0771f682e5d5a96fc7e5 Mon Sep 17 00:00:00 2001 From: Matteo Date: Fri, 28 Feb 2020 09:46:51 +0100 Subject: Ignore NotFound errors inside continue_eval It could have caused some errors if the user delete his own message --- bot/cogs/snekbox.py | 49 +++++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 381b309e0..d52027ac6 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -8,7 +8,7 @@ from functools import partial from signal import Signals from typing import Optional, Tuple -from discord import HTTPException, Message, Reaction, User +from discord import HTTPException, Message, NotFound, Reaction, User from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot @@ -218,29 +218,30 @@ class Snekbox(Cog): _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) - try: - _, new_message = await self.bot.wait_for( - 'message_edit', - check=_predicate_eval_message_edit, - timeout=10 - ) - await ctx.message.add_reaction(REEVAL_EMOJI) - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=10 - ) - - code = new_message.content.split(' ', maxsplit=1)[1] - await ctx.message.clear_reactions() - with contextlib.suppress(HTTPException): - await response.delete() - - except asyncio.TimeoutError: - await ctx.message.clear_reactions() - return None - - return code + with contextlib.suppress(NotFound): + try: + _, new_message = await self.bot.wait_for( + 'message_edit', + check=_predicate_eval_message_edit, + timeout=10 + ) + await ctx.message.add_reaction(REEVAL_EMOJI) + await self.bot.wait_for( + 'reaction_add', + check=_predicate_emoji_reaction, + timeout=10 + ) + + code = new_message.content.split(' ', maxsplit=1)[1] + await ctx.message.clear_reactions() + with contextlib.suppress(HTTPException): + await response.delete() + + except asyncio.TimeoutError: + await ctx.message.clear_reactions() + return None + + return code @command(name="eval", aliases=("e",)) @guild_only() -- cgit v1.2.3 From b8769e036e9247be47cea1b2073e92ea48724ca8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 09:41:37 -0800 Subject: Snekbox: mention re-evaluation feature in the command's docstring --- bot/cogs/snekbox.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 44764e7e9..cff7c5786 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -251,7 +251,10 @@ class Snekbox(Cog): Run Python code and get the results. This command supports multiple lines of code, including code wrapped inside a formatted code - block. We've done our best to make this safe, but do let us know if you manage to find an + block. Code can be re-evaluated by editing the original message within 10 seconds and + clicking the reaction that subsequently appears. + + We've done our best to make this sandboxed, but do let us know if you manage to find an issue with it! """ if ctx.author.id in self.jobs: -- cgit v1.2.3 From 5daf1db8ea9e86568da4907d42507aa3286eb3c1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 09:59:57 -0800 Subject: Scheduler: correct type annotations --- bot/utils/scheduling.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 1eae817c1..5760ec2d4 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,9 +1,9 @@ import asyncio import contextlib import logging +import typing as t from abc import abstractmethod from functools import partial -from typing import Dict from bot.utils import CogABCMeta @@ -17,10 +17,10 @@ class Scheduler(metaclass=CogABCMeta): # Keep track of the child cog's name so the logs are clear. self.cog_name = self.__class__.__name__ - self._scheduled_tasks: Dict[str, asyncio.Task] = {} + self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} @abstractmethod - async def _scheduled_task(self, task_object: dict) -> None: + async def _scheduled_task(self, task_object: t.Any) -> None: """ A coroutine which handles the scheduling. @@ -31,7 +31,7 @@ class Scheduler(metaclass=CogABCMeta): then make a site API request to delete the reminder from the database. """ - def schedule_task(self, task_id: str, task_data: dict) -> None: + def schedule_task(self, task_id: t.Hashable, task_data: t.Any) -> None: """ Schedules a task. @@ -51,7 +51,7 @@ class Scheduler(metaclass=CogABCMeta): self._scheduled_tasks[task_id] = task log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.") - def cancel_task(self, task_id: str) -> None: + def cancel_task(self, task_id: t.Hashable) -> None: """Unschedule the task identified by `task_id`.""" log.trace(f"{self.cog_name}: cancelling task #{task_id}...") task = self._scheduled_tasks.get(task_id) @@ -65,7 +65,7 @@ class Scheduler(metaclass=CogABCMeta): log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") - def _task_done_callback(self, task_id: str, done_task: asyncio.Task) -> None: + def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ Delete the task and raise its exception if one exists. -- cgit v1.2.3 From c2af442676011eb620593505789be4d34da76ea3 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 29 Feb 2020 17:06:51 +0100 Subject: Migrate snekbox tests to Python 3.8's unittest I've migrated the `tests/test_snekbox.py` file to use the new Python 3.8-style unittests instead of our old style using our custom Async mocks. In particular, I had to make a few changes: - Mocking the async post() context manager correctly Since `ClientSession.post` returns an async context manager when called, we need to make sure to assign the return value to the __aenter__ method of whatever `post()` returns, not of `post` itself (i.e.. when it's not called). - Use the new AsyncMock assert methods `assert_awaited_once` and `assert_awaited_once_with` Objects of the new `unittest.mock.AsyncMock` class have special methods to assert what they were called with that also assert that specific coroutine object was awaited. This means we test two things in one: Whether or not it was called with the right arguments and whether or not the returned coroutine object was then awaited. - Patch `functools.partial` as `partial` objects are compared by identity When you create two partial functions of the same function, you'll end up with two different `partial` objects. Since `partial` objects are compared by identity, you can't compare a `partial` created in a test method to that created in the callable you're trying to test. They will always compare as `False`. Since we're not interested in actually creating `partial` objects, I've just patched `functools.partial` in the namespace of the module we're testing to make sure we can compare them. --- tests/bot/cogs/test_snekbox.py | 68 +++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 41 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 985bc66a1..9cd7f0154 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -1,74 +1,68 @@ import asyncio import logging import unittest -from functools import partial -from unittest.mock import MagicMock, Mock, call, patch +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch from bot.cogs import snekbox from bot.cogs.snekbox import Snekbox from bot.constants import URLs -from tests.helpers import ( - AsyncContextManagerMock, AsyncMock, MockBot, MockContext, MockMessage, MockReaction, MockUser, async_test -) +from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser -class SnekboxTests(unittest.TestCase): +class SnekboxTests(unittest.IsolatedAsyncioTestCase): def setUp(self): """Add mocked bot and cog to the instance.""" self.bot = MockBot() - - self.mocked_post = MagicMock() - self.mocked_post.json = AsyncMock() - self.bot.http_session.post = MagicMock(return_value=AsyncContextManagerMock(self.mocked_post)) - self.cog = Snekbox(bot=self.bot) - @async_test async def test_post_eval(self): """Post the eval code to the URLs.snekbox_eval_api endpoint.""" - self.mocked_post.json.return_value = {'lemon': 'AI'} + resp = MagicMock() + resp.json = AsyncMock(return_value="return") + self.bot.http_session.post().__aenter__.return_value = resp - self.assertEqual(await self.cog.post_eval("import random"), {'lemon': 'AI'}) - self.bot.http_session.post.assert_called_once_with( + self.assertEqual(await self.cog.post_eval("import random"), "return") + self.bot.http_session.post.assert_called_with( URLs.snekbox_eval_api, json={"input": "import random"}, raise_for_status=True ) + resp.json.assert_awaited_once() - @async_test async def test_upload_output_reject_too_long(self): """Reject output longer than MAX_PASTE_LEN.""" result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) self.assertEqual(result, "too long to upload") - @async_test async def test_upload_output(self): """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" - key = "RainbowDash" - self.mocked_post.json.return_value = {"key": key} + key = "MarkDiamond" + resp = MagicMock() + resp.json = AsyncMock(return_value={"key": key}) + self.bot.http_session.post().__aenter__.return_value = resp self.assertEqual( await self.cog.upload_output("My awesome output"), URLs.paste_service.format(key=key) ) - self.bot.http_session.post.assert_called_once_with( + self.bot.http_session.post.assert_called_with( URLs.paste_service.format(key="documents"), data="My awesome output", raise_for_status=True ) - @async_test async def test_upload_output_gracefully_fallback_if_exception_during_request(self): """Output upload gracefully fallback if the upload fail.""" - self.mocked_post.json.side_effect = Exception + resp = MagicMock() + resp.json = AsyncMock(side_effect=Exception) + self.bot.http_session.post().__aenter__.return_value = resp + log = logging.getLogger("bot.cogs.snekbox") with self.assertLogs(logger=log, level='ERROR'): await self.cog.upload_output('My awesome output!') - @async_test async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): """Output upload gracefully fallback if there is no key entry in the response body.""" - self.mocked_post.json.return_value = {} self.assertEqual((await self.cog.upload_output('My awesome output!')), None) def test_prepare_input(self): @@ -121,7 +115,6 @@ class SnekboxTests(unittest.TestCase): actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) self.assertEqual(actual, expected) - @async_test async def test_format_output(self): """Test output formatting.""" self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') @@ -172,7 +165,6 @@ class SnekboxTests(unittest.TestCase): with self.subTest(msg=testname, case=case, expected=expected): self.assertEqual(await self.cog.format_output(case), expected) - @async_test async def test_eval_command_evaluate_once(self): """Test the eval command procedure.""" ctx = MockContext() @@ -186,7 +178,6 @@ class SnekboxTests(unittest.TestCase): self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode') self.cog.continue_eval.assert_called_once_with(ctx, response) - @async_test async def test_eval_command_evaluate_twice(self): """Test the eval and re-eval command procedure.""" ctx = MockContext() @@ -201,7 +192,6 @@ class SnekboxTests(unittest.TestCase): self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode') self.cog.continue_eval.assert_called_with(ctx, response) - @async_test async def test_eval_command_reject_two_eval_at_the_same_time(self): """Test if the eval command rejects an eval if the author already have a running eval.""" ctx = MockContext() @@ -214,7 +204,6 @@ class SnekboxTests(unittest.TestCase): "@LemonLemonishBeard#0042 You've already got a job running - please wait for it to finish!" ) - @async_test async def test_eval_command_call_help(self): """Test if the eval command call the help command if no code is provided.""" ctx = MockContext() @@ -222,14 +211,13 @@ class SnekboxTests(unittest.TestCase): await self.cog.eval_command.callback(self.cog, ctx=ctx, code='') ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") - @async_test async def test_send_eval(self): """Test the send_eval function.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' - ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) + self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) self.cog.get_status_emoji = MagicMock(return_value=':yay!:') @@ -244,14 +232,13 @@ class SnekboxTests(unittest.TestCase): self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) self.cog.format_output.assert_called_once_with('') - @async_test async def test_send_eval_with_paste_link(self): """Test the send_eval function with a too long output that generate a paste link.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' - ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) + self.cog.post_eval = AsyncMock(return_value={'stdout': 'Way too long beard', 'returncode': 0}) self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) self.cog.get_status_emoji = MagicMock(return_value=':yay!:') @@ -267,14 +254,12 @@ class SnekboxTests(unittest.TestCase): self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) self.cog.format_output.assert_called_once_with('Way too long beard') - @async_test async def test_send_eval_with_non_zero_eval(self): """Test the send_eval function with a code returning a non-zero code.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' - ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127}) self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Beard got stuck in the eval')) self.cog.get_status_emoji = MagicMock(return_value=':nope!:') @@ -289,8 +274,8 @@ class SnekboxTests(unittest.TestCase): self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) self.cog.format_output.assert_not_called() - @async_test - async def test_continue_eval_does_continue(self): + @patch("bot.cogs.snekbox.partial") + async def test_continue_eval_does_continue(self, partial_mock): """Test that the continue_eval function does continue if required conditions are met.""" ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock())) response = MockMessage(delete=AsyncMock()) @@ -299,15 +284,16 @@ class SnekboxTests(unittest.TestCase): actual = await self.cog.continue_eval(ctx, response) self.assertEqual(actual, 'NewCode') - self.bot.wait_for.has_calls( - call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), - call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) + self.bot.wait_for.assert_has_awaits( + ( + call('message_edit', check=partial_mock(snekbox.predicate_eval_message_edit, ctx), timeout=10), + call('reaction_add', check=partial_mock(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) + ) ) ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) ctx.message.clear_reactions.assert_called_once() response.delete.assert_called_once() - @async_test async def test_continue_eval_does_not_continue(self): ctx = MockContext(message=MockMessage(clear_reactions=AsyncMock())) self.bot.wait_for.side_effect = asyncio.TimeoutError -- cgit v1.2.3 From d2341a5fbf06dc2b541a88d3dfbd6a9deed1dc28 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 26 Feb 2020 15:30:07 -0800 Subject: Install the coloredlogs package This makes it easy to add colour to the logs. Colorama is also installed if on a Windows system. --- Pipfile | 2 ++ Pipfile.lock | 104 ++++++++++++++++++++++++++++++++++++----------------------- 2 files changed, 66 insertions(+), 40 deletions(-) diff --git a/Pipfile b/Pipfile index 400e64c18..88aacf6a8 100644 --- a/Pipfile +++ b/Pipfile @@ -19,6 +19,8 @@ requests = "~=2.22" more_itertools = "~=7.2" urllib3 = ">=1.24.2,<1.25" sentry-sdk = "~=0.14" +coloredlogs = "~=14.0" +colorama = {version = "~=0.4.3", sys_platform = "== 'win32'"} [dev-packages] coverage = "~=4.5" diff --git a/Pipfile.lock b/Pipfile.lock index fa29bf995..f645698f2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c7706a61eb96c06d073898018ea2dbcf5bd3b15d007496e2d60120a65647f31e" + "sha256": "f9dda521aa7816ca575b33e0f2e4e7e434682a0add9d74f0e89addae65453cd6" }, "pipfile-spec": 6, "requires": { @@ -140,6 +140,23 @@ ], "version": "==3.0.4" }, + "colorama": { + "hashes": [ + "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", + "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" + ], + "index": "pypi", + "markers": "sys_platform == 'win32'", + "version": "==0.4.3" + }, + "coloredlogs": { + "hashes": [ + "sha256:346f58aad6afd48444c2468618623638dadab76e4e70d5e10822676f2d32226a", + "sha256:a1fab193d2053aa6c0a97608c4342d031f1f93a3d1218432c59322441d31a505" + ], + "index": "pypi", + "version": "==14.0" + }, "deepdiff": { "hashes": [ "sha256:b3fa588d1eac7fa318ec1fb4f2004568e04cb120a1989feda8e5e7164bcbf07a", @@ -150,10 +167,10 @@ }, "discord-py": { "hashes": [ - "sha256:8bfe5628d31771744000f19135c386c74ac337479d7282c26cc1627b9d31f360" + "sha256:7424be26b07b37ecad4404d9383d685995a0e0b3df3f9c645bdd3a4d977b83b4" ], "index": "pypi", - "version": "==1.3.1" + "version": "==1.3.2" }, "docutils": { "hashes": [ @@ -170,6 +187,13 @@ "index": "pypi", "version": "==0.18.0" }, + "humanfriendly": { + "hashes": [ + "sha256:5e5c2b82fb58dcea413b48ab2a7381baa5e246d47fe94241d7d83724c11c0565", + "sha256:a9a41074c24dc5d6486e8784dc8f057fec8b963217e941c25fb7c7c383a4a1c1" + ], + "version": "==7.1.1" + }, "idna": { "hashes": [ "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", @@ -279,25 +303,25 @@ }, "multidict": { "hashes": [ - "sha256:13f3ebdb5693944f52faa7b2065b751cb7e578b8dd0a5bb8e4ab05ad0188b85e", - "sha256:26502cefa86d79b86752e96639352c7247846515c864d7c2eb85d036752b643c", - "sha256:4fba5204d32d5c52439f88437d33ad14b5f228e25072a192453f658bddfe45a7", - "sha256:527124ef435f39a37b279653ad0238ff606b58328ca7989a6df372fd75d7fe26", - "sha256:5414f388ffd78c57e77bd253cf829373721f450613de53dc85a08e34d806e8eb", - "sha256:5eee66f882ab35674944dfa0d28b57fa51e160b4dce0ce19e47f495fdae70703", - "sha256:63810343ea07f5cd86ba66ab66706243a6f5af075eea50c01e39b4ad6bc3c57a", - "sha256:6bd10adf9f0d6a98ccc792ab6f83d18674775986ba9bacd376b643fe35633357", - "sha256:83c6ddf0add57c6b8a7de0bc7e2d656be3eefeff7c922af9a9aae7e49f225625", - "sha256:93166e0f5379cf6cd29746989f8a594fa7204dcae2e9335ddba39c870a287e1c", - "sha256:9a7b115ee0b9b92d10ebc246811d8f55d0c57e82dbb6a26b23c9a9a6ad40ce0c", - "sha256:a38baa3046cce174a07a59952c9f876ae8875ef3559709639c17fdf21f7b30dd", - "sha256:a6d219f49821f4b2c85c6d426346a5d84dab6daa6f85ca3da6c00ed05b54022d", - "sha256:a8ed33e8f9b67e3b592c56567135bb42e7e0e97417a4b6a771e60898dfd5182b", - "sha256:d7d428488c67b09b26928950a395e41cc72bb9c3d5abfe9f0521940ee4f796d4", - "sha256:dcfed56aa085b89d644af17442cdc2debaa73388feba4b8026446d168ca8dad7", - "sha256:f29b885e4903bd57a7789f09fe9d60b6475a6c1a4c0eca874d8558f00f9d4b51" - ], - "version": "==4.7.4" + "sha256:317f96bc0950d249e96d8d29ab556d01dd38888fbe68324f46fd834b430169f1", + "sha256:42f56542166040b4474c0c608ed051732033cd821126493cf25b6c276df7dd35", + "sha256:4b7df040fb5fe826d689204f9b544af469593fb3ff3a069a6ad3409f742f5928", + "sha256:544fae9261232a97102e27a926019100a9db75bec7b37feedd74b3aa82f29969", + "sha256:620b37c3fea181dab09267cd5a84b0f23fa043beb8bc50d8474dd9694de1fa6e", + "sha256:6e6fef114741c4d7ca46da8449038ec8b1e880bbe68674c01ceeb1ac8a648e78", + "sha256:7774e9f6c9af3f12f296131453f7b81dabb7ebdb948483362f5afcaac8a826f1", + "sha256:85cb26c38c96f76b7ff38b86c9d560dea10cf3459bb5f4caf72fc1bb932c7136", + "sha256:a326f4240123a2ac66bb163eeba99578e9d63a8654a59f4688a79198f9aa10f8", + "sha256:ae402f43604e3b2bc41e8ea8b8526c7fa7139ed76b0d64fc48e28125925275b2", + "sha256:aee283c49601fa4c13adc64c09c978838a7e812f85377ae130a24d7198c0331e", + "sha256:b51249fdd2923739cd3efc95a3d6c363b67bbf779208e9f37fd5e68540d1a4d4", + "sha256:bb519becc46275c594410c6c28a8a0adc66fe24fef154a9addea54c1adb006f5", + "sha256:c2c37185fb0af79d5c117b8d2764f4321eeb12ba8c141a95d0aa8c2c1d0a11dd", + "sha256:dc561313279f9d05a3d0ffa89cd15ae477528ea37aa9795c4654588a3287a9ab", + "sha256:e439c9a10a95cb32abd708bb8be83b2134fa93790a4fb0535ca36db3dda94d20", + "sha256:fc3b4adc2ee8474cb3cd2a155305d5f8eda0a9c91320f83e55748e1fcb68f8e3" + ], + "version": "==4.7.5" }, "ordered-set": { "hashes": [ @@ -415,11 +439,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:b06dd27391fd11fb32f84fe054e6a64736c469514a718a99fb5ce1dff95d6b28", - "sha256:e023da07cfbead3868e1e2ba994160517885a32dfd994fc455b118e37989479b" + "sha256:480eee754e60bcae983787a9a13bc8f155a111aef199afaa4f289d6a76aa622a", + "sha256:a920387dc3ee252a66679d0afecd34479fb6fc52c2bc20763793ed69e5b0dcc0" ], "index": "pypi", - "version": "==0.14.1" + "version": "==0.14.2" }, "six": { "hashes": [ @@ -437,18 +461,18 @@ }, "soupsieve": { "hashes": [ - "sha256:bdb0d917b03a1369ce964056fc195cfdff8819c40de04695a80bc813c3cfa1f5", - "sha256:e2c1c5dee4a1c36bcb790e0fabd5492d874b8ebd4617622c4f6a731701060dda" + "sha256:e914534802d7ffd233242b785229d5ba0766a7f487385e3f714446a07bf540ae", + "sha256:fcd71e08c0aee99aca1b73f45478549ee7e7fc006d51b37bec9e9def7dc22b69" ], - "version": "==1.9.5" + "version": "==2.0" }, "sphinx": { "hashes": [ - "sha256:525527074f2e0c2585f68f73c99b4dc257c34bbe308b27f5f8c7a6e20642742f", - "sha256:543d39db5f82d83a5c1aa0c10c88f2b6cff2da3e711aa849b2c627b4b403bbd9" + "sha256:776ff8333181138fae52df65be733127539623bb46cc692e7fa0fcfc80d7aa88", + "sha256:ca762da97c3b5107cbf0ab9e11d3ec7ab8d3c31377266fd613b962ed971df709" ], "index": "pypi", - "version": "==2.4.2" + "version": "==2.4.3" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -466,10 +490,10 @@ }, "sphinxcontrib-htmlhelp": { "hashes": [ - "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", - "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" + "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", + "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], - "version": "==1.0.2" + "version": "==1.0.3" }, "sphinxcontrib-jsmath": { "hashes": [ @@ -581,10 +605,10 @@ }, "cfgv": { "hashes": [ - "sha256:04b093b14ddf9fd4d17c53ebfd55582d27b76ed30050193c14e560770c5360eb", - "sha256:f22b426ed59cd2ab2b54ff96608d846c33dfb8766a67f0b4a6ce130ce244414f" + "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53", + "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513" ], - "version": "==3.0.0" + "version": "==3.1.0" }, "chardet": { "hashes": [ @@ -913,10 +937,10 @@ }, "virtualenv": { "hashes": [ - "sha256:08f3623597ce73b85d6854fb26608a6f39ee9d055c81178dc6583803797f8994", - "sha256:de2cbdd5926c48d7b84e0300dea9e8f276f61d186e8e49223d71d91250fbaebd" + "sha256:30ea90b21dabd11da5f509710ad3be2ae47d40ccbc717dfdd2efe4367c10f598", + "sha256:4a36a96d785428278edd389d9c36d763c5755844beb7509279194647b1ef47f1" ], - "version": "==20.0.4" + "version": "==20.0.7" }, "zipp": { "hashes": [ -- cgit v1.2.3 From 69440ead8d0592bf129ae046cb3565c24816c69c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 08:00:37 -0800 Subject: Make logs coloured! --- bot/__init__.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/bot/__init__.py b/bot/__init__.py index f7a410706..c9dbc3f40 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -1,9 +1,11 @@ import logging import os import sys -from logging import Logger, StreamHandler, handlers +from logging import Logger, handlers from pathlib import Path +import coloredlogs + TRACE_LEVEL = logging.TRACE = 5 logging.addLevelName(TRACE_LEVEL, "TRACE") @@ -25,10 +27,9 @@ Logger.trace = monkeypatch_trace DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local") -log_format = logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s") - -stream_handler = StreamHandler(stream=sys.stdout) -stream_handler.setFormatter(log_format) +log_level = TRACE_LEVEL if DEBUG_MODE else logging.INFO +format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s" +log_format = logging.Formatter(format_string) log_file = Path("logs", "bot.log") log_file.parent.mkdir(exist_ok=True) @@ -36,10 +37,25 @@ file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCo file_handler.setFormatter(log_format) root_log = logging.getLogger() -root_log.setLevel(TRACE_LEVEL if DEBUG_MODE else logging.INFO) -root_log.addHandler(stream_handler) +root_log.setLevel(log_level) root_log.addHandler(file_handler) +if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: + coloredlogs.DEFAULT_LEVEL_STYLES = { + **coloredlogs.DEFAULT_LEVEL_STYLES, + "trace": {"color": 246}, + "critical": {"background": "red"}, + "debug": coloredlogs.DEFAULT_LEVEL_STYLES["info"] + } + +if "COLOREDLOGS_LOG_FORMAT" not in os.environ: + coloredlogs.DEFAULT_LOG_FORMAT = format_string + +if "COLOREDLOGS_LOG_LEVEL" not in os.environ: + coloredlogs.DEFAULT_LOG_LEVEL = log_level + +coloredlogs.install(logger=root_log, stream=sys.stdout) + logging.getLogger("discord").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger(__name__) -- cgit v1.2.3 From e602889dedb9bd5db42a55274c11a74f42b9b700 Mon Sep 17 00:00:00 2001 From: Spencer Young Date: Sun, 1 Mar 2020 06:44:16 -0800 Subject: Optimize Dockerfile --- Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 271c25050..2fba8cf68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,12 +9,15 @@ ENV PIP_NO_CACHE_DIR=false \ # Install pipenv RUN pip install -U pipenv -# Copy project files into working directory +# Create the working directory WORKDIR /bot -COPY . . # Install project dependencies +COPY Pipfile* ./ RUN pipenv install --system --deploy +# Copy the source code in last to optimize rebuilding the image +COPY . . + ENTRYPOINT ["python3"] CMD ["-m", "bot"] -- cgit v1.2.3 From dfade671e0a04aacde5c7d6bca290fc4c69dcc58 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sun, 1 Mar 2020 13:33:22 -0500 Subject: Bump Dependencies & Relock * Remove explicit urllib3 pinning, CVE that caused its pinning has been resolved by 1.25+. This is a child dependency of requests. --- Pipfile | 13 +++--- Pipfile.lock | 141 +++++++++++++++++++++++++++++------------------------------ 2 files changed, 76 insertions(+), 78 deletions(-) diff --git a/Pipfile b/Pipfile index 9ac32886a..64760f9dd 100644 --- a/Pipfile +++ b/Pipfile @@ -16,25 +16,24 @@ aio-pika = "~=6.1" python-dateutil = "~=2.8" deepdiff = "~=4.0" requests = "~=2.22" -more_itertools = "~=7.2" -urllib3 = ">=1.24.2,<1.25" +more_itertools = "~=8.2" sentry-sdk = "~=0.14" coloredlogs = "~=14.0" colorama = {version = "~=0.4.3", sys_platform = "== 'win32'"} [dev-packages] -coverage = "~=4.5" +coverage = "~=5.0" flake8 = "~=3.7" flake8-annotations = "~=2.0" -flake8-bugbear = "~=19.8" +flake8-bugbear = "~=20.1" flake8-docstrings = "~=1.4" flake8-import-order = "~=0.18" flake8-string-format = "~=0.2" -flake8-tidy-imports = "~=2.0" +flake8-tidy-imports = "~=4.0" flake8-todo = "~=0.7" -pre-commit = "~=1.18" +pre-commit = "~=2.1" safety = "~=1.8" -unittest-xml-reporting = "~=2.5" +unittest-xml-reporting = "~=3.0" dodgy = "~=0.1" [requires] diff --git a/Pipfile.lock b/Pipfile.lock index 91d7d5430..9953aab40 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1128d3fc064359337cba08ddf7236982c09f714ca148861a22c4ef623e728c49" + "sha256": "fae6dcdb6a5ebf27e8ea5044f4ca2ab854774d17affb5fd64ac85f8d0ae71187" }, "pipfile-spec": 6, "requires": { @@ -189,10 +189,10 @@ }, "humanfriendly": { "hashes": [ - "sha256:5e5c2b82fb58dcea413b48ab2a7381baa5e246d47fe94241d7d83724c11c0565", - "sha256:a9a41074c24dc5d6486e8784dc8f057fec8b963217e941c25fb7c7c383a4a1c1" + "sha256:cbe04ecf964ccb951a578f396091f258448ca4b4b4c6d4b6194f48ef458fe991", + "sha256:e8e2e4524409e55d5c5cbbb4c555a0c0a9599d5e8f74d0ce1ac504ba51ad1cd2" ], - "version": "==7.1.1" + "version": "==7.2" }, "idna": { "hashes": [ @@ -295,11 +295,11 @@ }, "more-itertools": { "hashes": [ - "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", - "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" + "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c", + "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507" ], "index": "pypi", - "version": "==7.2.0" + "version": "==8.2.0" }, "multidict": { "hashes": [ @@ -379,7 +379,8 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3", + "sha256:fd64020e8a5e0369de455adf9f22795a90fdb74e6bb999e9a13fd26b54f533ef" ], "version": "==2.19" }, @@ -397,6 +398,13 @@ ], "version": "==2.4.6" }, + "pyreadline": { + "hashes": [ + "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1" + ], + "markers": "sys_platform == 'win32'", + "version": "==2.1" + }, "python-dateutil": { "hashes": [ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", @@ -518,11 +526,10 @@ }, "urllib3": { "hashes": [ - "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", - "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" + "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", + "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" ], - "index": "pypi", - "version": "==1.24.3" + "version": "==1.25.8" }, "websockets": { "hashes": [ @@ -582,13 +589,6 @@ ], "version": "==1.4.3" }, - "aspy.yaml": { - "hashes": [ - "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc", - "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45" - ], - "version": "==1.3.0" - }, "attrs": { "hashes": [ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", @@ -626,45 +626,45 @@ }, "coverage": { "hashes": [ - "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", - "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", - "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", - "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", - "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", - "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", - "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", - "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", - "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", - "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", - "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", - "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", - "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", - "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", - "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", - "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", - "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", - "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", - "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", - "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", - "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", - "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", - "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", - "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", - "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", - "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", - "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", - "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", - "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", - "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", - "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", - "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" - ], - "index": "pypi", - "version": "==4.5.4" + "sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3", + "sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c", + "sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0", + "sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477", + "sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a", + "sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf", + "sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691", + "sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73", + "sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987", + "sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894", + "sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e", + "sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef", + "sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf", + "sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68", + "sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8", + "sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954", + "sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2", + "sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40", + "sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc", + "sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc", + "sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e", + "sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d", + "sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f", + "sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc", + "sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301", + "sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea", + "sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb", + "sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af", + "sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52", + "sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37", + "sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0" + ], + "index": "pypi", + "version": "==5.0.3" }, "distlib": { "hashes": [ - "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21" + "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21", + "sha256:9b183fb98f4870e02d315d5d17baef14be74c339d827346cae544f5597698555" ], "version": "==0.3.0" }, @@ -715,11 +715,11 @@ }, "flake8-bugbear": { "hashes": [ - "sha256:d8c466ea79d5020cb20bf9f11cf349026e09517a42264f313d3f6fddb83e0571", - "sha256:ded4d282778969b5ab5530ceba7aa1a9f1b86fa7618fc96a19a1d512331640f8" + "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63", + "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162" ], "index": "pypi", - "version": "==19.8.0" + "version": "==20.1.4" }, "flake8-docstrings": { "hashes": [ @@ -747,11 +747,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:1c476aabc6e8db26dc75278464a3a392dba0ea80562777c5f13fd5cdf2646154", - "sha256:b3f5b96affd0f57cacb6621ed28286ce67edaca807757b51227043ebf7b136a1" + "sha256:8aa34384b45137d4cf33f5818b8e7897dc903b1d1e10a503fa7dd193a9a710ba", + "sha256:b26461561bcc80e8012e46846630ecf0aaa59314f362a94cb7800dfdb32fa413" ], "index": "pypi", - "version": "==2.0.0" + "version": "==4.0.0" }, "flake8-todo": { "hashes": [ @@ -796,11 +796,11 @@ }, "pre-commit": { "hashes": [ - "sha256:8f48d8637bdae6fa70cc97db9c1dd5aa7c5c8bf71968932a380628c25978b850", - "sha256:f92a359477f3252452ae2e8d3029de77aec59415c16ae4189bcfba40b757e029" + "sha256:09ebe467f43ce24377f8c2f200fe3cd2570d328eb2ce0568c8e96ce19da45fa6", + "sha256:f8d555e31e2051892c7f7b3ad9f620bd2c09271d87e9eedb2ad831737d6211eb" ], "index": "pypi", - "version": "==1.21.0" + "version": "==2.1.1" }, "pycodestyle": { "hashes": [ @@ -886,19 +886,18 @@ }, "unittest-xml-reporting": { "hashes": [ - "sha256:358bbdaf24a26d904cc1c26ef3078bca7fc81541e0a54c8961693cc96a6f35e0", - "sha256:9d28ddf6524cf0ff9293f61bd12e792de298f8561a5c945acea63fb437789e0e" + "sha256:74eaf7739a7957a74f52b8187c5616f61157372189bef0a32ba5c30bbc00e58a", + "sha256:e09b8ae70cce9904cdd331f53bf929150962869a5324ab7ff3dd6c8b87e01f7d" ], "index": "pypi", - "version": "==2.5.2" + "version": "==3.0.2" }, "urllib3": { "hashes": [ - "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", - "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" + "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", + "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" ], - "index": "pypi", - "version": "==1.24.3" + "version": "==1.25.8" }, "virtualenv": { "hashes": [ -- cgit v1.2.3 From 9cca41b5d8943212b38eae3ae78e6472577ba6c1 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sun, 1 Mar 2020 13:59:17 -0500 Subject: Move syncer confirmation reaction check out of finally clause Returning directly out of a `finally` clause can cause any exceptions raised in the clause to be discarded, so we can remove the finally clause entirely and shift the control statements into the body of the function --- bot/cogs/sync/syncers.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index d6891168f..c7ce54d65 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -125,17 +125,17 @@ class Syncer(abc.ABC): except TimeoutError: # reaction will remain none thus sync will be aborted in the finally block below. log.debug(f"The {self.name} syncer confirmation prompt timed out.") - finally: - if str(reaction) == constants.Emojis.check_mark: - log.trace(f"The {self.name} syncer was confirmed.") - await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') - return True - else: - log.warning(f"The {self.name} syncer was aborted or timed out!") - await message.edit( - content=f':warning: {mention}{self.name} sync aborted or timed out!' - ) - return False + + if str(reaction) == constants.Emojis.check_mark: + log.trace(f"The {self.name} syncer was confirmed.") + await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') + return True + else: + log.warning(f"The {self.name} syncer was aborted or timed out!") + await message.edit( + content=f':warning: {mention}{self.name} sync aborted or timed out!' + ) + return False @abc.abstractmethod async def _get_diff(self, guild: Guild) -> _Diff: -- cgit v1.2.3 From 91c6bcd0dfbaad201ee47af2ee7e36e4f372a115 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sun, 1 Mar 2020 14:27:14 -0500 Subject: Modify log test regex to be non-os-specific Previous regex utilized a `/`, which doesn't work for comparing against Windows paths, which use `\` --- tests/test_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_base.py b/tests/test_base.py index 235a2ee6c..a7db4bf3e 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -29,7 +29,7 @@ class LoggingTestCaseTests(unittest.TestCase): """Test if LoggingTestCase.assertNotLogs raises AssertionError when logs were emitted.""" msg_regex = ( r"1 logs of DEBUG or higher were triggered on root:\n" - r'' + r'' ) with self.assertRaisesRegex(AssertionError, msg_regex): with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): -- cgit v1.2.3 From db1f74654f1999eaa9b0ae9d8dc49b073222f70b Mon Sep 17 00:00:00 2001 From: Joseph Date: Sun, 1 Mar 2020 21:03:54 +0000 Subject: Add grabify (IP logger) domains to banned domains --- config-default.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/config-default.yml b/config-default.yml index ab237423f..9beb610cc 100644 --- a/config-default.yml +++ b/config-default.yml @@ -284,6 +284,30 @@ filter: domain_blacklist: - pornhub.com - liveleak.com + - grabify.link + - bmwforum.co + - leancoding.co + - spottyfly.com + - stopify.co + - yoรผtu.be + - discรถrd.com + - minecrรคft.com + - freegiftcards.co + - disรงordapp.com + - fortnight.space + - fortnitechat.site + - joinmy.site + - curiouscat.club + - catsnthings.fun + - yourtube.site + - youtubeshort.watch + - catsnthing.com + - youtubeshort.pro + - canadianlumberjacks.online + - poweredbydialup.club + - poweredbydialup.online + - poweredbysecurity.org + - poweredbysecurity.online word_watchlist: - goo+ks* -- cgit v1.2.3 From cec9d77a0f8a738f74d02848265a6af86a9cc2d4 Mon Sep 17 00:00:00 2001 From: Leon Sandรธy Date: Mon, 2 Mar 2020 13:10:40 +0100 Subject: Adding helpers to the Filtering whitelist Resolves an issue mentioned in https://github.com/python-discord/bot/issues/767, giving Helpers access to post invites and other things caught by the Filtering cog. --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index 9beb610cc..5788d1e12 100644 --- a/config-default.yml +++ b/config-default.yml @@ -353,6 +353,7 @@ filter: - *ADMINS_ROLE - *MODS_ROLE - *OWNERS_ROLE + - *HELPERS_ROLE - *PY_COMMUNITY_ROLE -- cgit v1.2.3