From f0795ea53247501cc38615f57aabe21685de7251 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 5 May 2020 02:19:03 +0200 Subject: Create utility function for uploading to paste service. --- bot/utils/__init__.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 9b32e515d..0f39a1bc8 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,9 +1,42 @@ +import logging from abc import ABCMeta +from typing import Optional +from aiohttp import ClientConnectorError, ClientSession from discord.ext.commands import CogMeta +from bot.constants import URLs + +log = logging.getLogger(__name__) + class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" pass + + +async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: + """ + Upload `contents` to the paste service. + + `http_session` should be the current running ClientSession from aiohttp + `extension` is added to the output URL + + When an error occurs, `None` is returned, otherwise the generated URL with the suffix. + """ + extension = extension and f".{extension}" + log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") + paste_url = URLs.paste_service.format(key="documents") + try: + async with http_session.post(paste_url, data=contents) as response: + response_json = await response.json() + except ClientConnectorError: + log.warning(f"Failed to connect to paste service at url {paste_url}.") + return + if "message" in response_json: + log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") + return + elif "key" in response_json: + log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From 4980726e3a68bb2bca966c9c3e09568da2162af0 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 5 May 2020 02:20:01 +0200 Subject: Attempt requests multiple times with connection errors. --- bot/utils/__init__.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 0f39a1bc8..6b9c890c8 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -9,6 +9,8 @@ from bot.constants import URLs log = logging.getLogger(__name__) +FAILED_REQUEST_ATTEMPTS = 3 + class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" @@ -28,15 +30,18 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e extension = extension and f".{extension}" log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") paste_url = URLs.paste_service.format(key="documents") - try: - async with http_session.post(paste_url, data=contents) as response: - response_json = await response.json() - except ClientConnectorError: - log.warning(f"Failed to connect to paste service at url {paste_url}.") - return - if "message" in response_json: - log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") - return - elif "key" in response_json: - log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") - return URLs.paste_service.format(key=response_json['key']) + extension + for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): + try: + async with http_session.post(paste_url, data=contents) as response: + response_json = await response.json() + except ClientConnectorError: + log.warning( + f"Failed to connect to paste service at url {paste_url}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + if "message" in response_json: + log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") + return + elif "key" in response_json: + log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From 2644316b07fdecbe834083c761ab5c7731e60a09 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:20:57 +0200 Subject: Send long eval output to paste service. --- bot/cogs/eval.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 52136fc8d..b739668b0 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -15,6 +15,7 @@ from bot.bot import Bot from bot.constants import Roles from bot.decorators import with_role from bot.interpreter import Interpreter +from bot.utils import send_to_paste_service log = logging.getLogger(__name__) @@ -171,6 +172,15 @@ async def func(): # (None,) -> Any res = traceback.format_exc() out, embed = self._format(code, res) + if len(out) > 1500 or out.count("\n") > 15: + paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + await ctx.send( + f"```py\n{out[:1500]}\n```" + f"... response truncated; full contents at {paste_link}", + embed=embed + ) + return + await ctx.send(f"```py\n{out}```", embed=embed) @group(name='internal', aliases=('int',)) -- cgit v1.2.3 From 1b98e6c839a3e841115fb6c150855e673bc1ef5b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:43:08 +0200 Subject: Increase log level. --- bot/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 6b9c890c8..011e41227 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -43,5 +43,5 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") return elif "key" in response_json: - log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From d0d205409ccf00b14f535573b343831f31bd917c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:43:42 +0200 Subject: Handle failed paste uploads. --- bot/cogs/eval.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index b739668b0..c75c1e55f 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -174,9 +174,14 @@ async def func(): # (None,) -> Any out, embed = self._format(code, res) if len(out) > 1500 or out.count("\n") > 15: paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + if paste_link is not None: + paste_text = f"full contents at {paste_link}" + else: + paste_text = "failed to upload contents to paste service." + await ctx.send( f"```py\n{out[:1500]}\n```" - f"... response truncated; full contents at {paste_link}", + f"... response truncated; {paste_text}", embed=embed ) return -- cgit v1.2.3 From 077a1ef1eb4eb07325dde5b6b625a84ccb5669ee Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:47:15 +0200 Subject: Use new util function for uploading output. --- bot/cogs/snekbox.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 8d4688114..2aab8fdb1 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -14,6 +14,7 @@ from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot from bot.constants import Categories, Channels, Roles, URLs from bot.decorators import in_whitelist +from bot.utils import send_to_paste_service from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -70,17 +71,7 @@ class Snekbox(Cog): if len(output) > MAX_PASTE_LEN: log.info("Full output is too long to upload") return "too long to upload" - - url = URLs.paste_service.format(key="documents") - try: - async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: - data = await resp.json() - - if "key" in data: - return URLs.paste_service.format(key=data["key"]) - except Exception: - # 400 (Bad Request) means there are too many characters - log.exception("Failed to upload full output to paste service!") + return await send_to_paste_service(self.bot.http_session, output, extension="txt") @staticmethod def prepare_input(code: str) -> str: -- cgit v1.2.3 From c94c0eaef4ccb64ee3f664ed65837b1f5afd5c59 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:43:25 +0200 Subject: Continue on failed connections. Not using skipping the iteration but continuing directly caused `response_json` being checked but not defined in case of connection errors. Co-authored-by: MarkKoz --- bot/utils/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 011e41227..41e54c3d5 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -39,6 +39,7 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e f"Failed to connect to paste service at url {paste_url}, " f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) + continue if "message" in response_json: log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") return -- cgit v1.2.3 From 93a805d950f9daf14ba50131547d888e1f6314b3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:45:28 +0200 Subject: Handle broad exceptions. In the case an unexpected exception happens, this allows us to try the request again or let the function exit gracefully in the case of multiple fails. --- bot/utils/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 41e54c3d5..b9290e5a6 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -40,6 +40,13 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) continue + except Exception: + log.exception( + f"An unexpected error has occurred during handling of the request, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + if "message" in response_json: log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") return -- cgit v1.2.3 From 8f7551540cc2770b498bfe38a9f72c0950bbd929 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:51:27 +0200 Subject: continue on internal server errors. In the case we receive `"message"` in the json response, the server had an internal error and we can attempt the request again. --- bot/utils/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index b9290e5a6..b273b2cde 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -48,8 +48,11 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e continue if "message" in response_json: - log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") - return + log.warning( + f"Paste service returned error {response_json['message']} with status code {response.status}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue elif "key" in response_json: log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From d98a418f9cafc8ce907293cb833cabfd68c92fb3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:52:44 +0200 Subject: Log unexpected JSON responses. --- bot/utils/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index b273b2cde..ec7cbd214 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -56,3 +56,7 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e elif "key" in response_json: log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") return URLs.paste_service.format(key=response_json['key']) + extension + log.warning( + f"Got unexpected JSON response from paste service: {response_json}\n" + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) -- cgit v1.2.3 From 5b11b248b945cd2a732c6d8d430d117fc062cc8d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 16:46:32 +0200 Subject: Remove tests from moved function. --- tests/bot/cogs/test_snekbox.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1dec0ccaf..d32d80ead 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -1,5 +1,4 @@ import asyncio -import logging import unittest from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch @@ -53,20 +52,6 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): raise_for_status=True ) - async def test_upload_output_gracefully_fallback_if_exception_during_request(self): - """Output upload gracefully fallback if the upload fail.""" - 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 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.assertEqual((await self.cog.upload_output('My awesome output!')), None) - def test_prepare_input(self): cases = ( ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), -- cgit v1.2.3 From 14c670dfa87e142e24c027e2976fa02b07c4d7ac Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 17:11:56 +0200 Subject: Adjust behaviour for new func usage. --- tests/bot/cogs/test_snekbox.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index d32d80ead..f4c13fc43 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -35,21 +35,12 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) self.assertEqual(result, "too long to upload") - async def test_upload_output(self): + @patch("bot.cogs.snekbox.send_to_paste_service") + async def test_upload_output(self, mock_paste_util): """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" - 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"), - constants.URLs.paste_service.format(key=key) - ) - self.bot.http_session.post.assert_called_with( - constants.URLs.paste_service.format(key="documents"), - data="My awesome output", - raise_for_status=True + await self.cog.upload_output("Test output.") + mock_paste_util.assert_called_once_with( + self.bot.http_session, "Test output.", extension="txt" ) def test_prepare_input(self): -- cgit v1.2.3 From 5d96e96a2e8982ec57c1a19d1a085ceccd35a6d7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 8 May 2020 01:38:14 +0200 Subject: Add tests for `send_to_paste_service`. --- tests/bot/utils/test_init.py | 74 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/bot/utils/test_init.py diff --git a/tests/bot/utils/test_init.py b/tests/bot/utils/test_init.py new file mode 100644 index 000000000..f3a8f5939 --- /dev/null +++ b/tests/bot/utils/test_init.py @@ -0,0 +1,74 @@ +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientConnectorError + +from bot.utils import FAILED_REQUEST_ATTEMPTS, send_to_paste_service + + +class PasteTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.http_session = MagicMock() + + @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") + async def test_url_and_sent_contents(self): + """Correct url was used and post was called with expected data.""" + response = MagicMock( + json=AsyncMock(return_value={"key": ""}) + ) + self.http_session.post().__aenter__.return_value = response + self.http_session.post.reset_mock() + await send_to_paste_service(self.http_session, "Content") + self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") + + @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") + async def test_paste_returns_correct_url_on_success(self): + """Url with specified extension is returned on successful requests.""" + key = "paste_key" + test_cases = ( + (f"https://paste_service.com/{key}.txt", "txt"), + (f"https://paste_service.com/{key}.py", "py"), + (f"https://paste_service.com/{key}", ""), + ) + response = MagicMock( + json=AsyncMock(return_value={"key": key}) + ) + self.http_session.post().__aenter__.return_value = response + + for expected_output, extension in test_cases: + with self.subTest(msg=f"Send contents with extension {repr(extension)}"): + self.assertEqual( + await send_to_paste_service(self.http_session, "", extension=extension), + expected_output + ) + + async def test_request_repeated_on_json_errors(self): + """Json with error message and invalid json are handled as errors and requests repeated.""" + test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) + self.http_session.post().__aenter__.return_value = response = MagicMock() + self.http_session.post.reset_mock() + + for error_json in test_cases: + with self.subTest(error_json=error_json): + response.json = AsyncMock(return_value=error_json) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + self.http_session.post.reset_mock() + + async def test_request_repeated_on_connection_errors(self): + """Requests are repeated in the case of connection errors.""" + self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + async def test_general_error_handled_and_request_repeated(self): + """All `Exception`s are handled, logged and request repeated.""" + self.http_session.post = MagicMock(side_effect=Exception) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertLogs("bot.utils", logging.ERROR) + self.assertIsNone(result) -- cgit v1.2.3 From 0b266169160a8368a3c7eba3fcdfb404b657232e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 30 May 2020 02:41:05 +0200 Subject: Truncate amount of lines in int eval output to 15. Previously the amount of newlines was checked and uploaded to the paste service if above 15 but the sent message was not truncated to only include that amount of lines. --- bot/cogs/eval.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index c75c1e55f..edb59d286 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -172,7 +172,15 @@ async def func(): # (None,) -> Any res = traceback.format_exc() out, embed = self._format(code, res) - if len(out) > 1500 or out.count("\n") > 15: + # Truncate output to max 15 lines or 1500 characters + newline_truncate_index = find_nth_occurrence(out, "\n", 15) + + if newline_truncate_index is None or newline_truncate_index > 1500: + truncate_index = 1500 + else: + truncate_index = newline_truncate_index + + if len(out) > truncate_index: paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") if paste_link is not None: paste_text = f"full contents at {paste_link}" @@ -180,7 +188,7 @@ async def func(): # (None,) -> Any paste_text = "failed to upload contents to paste service." await ctx.send( - f"```py\n{out[:1500]}\n```" + f"```py\n{out[:truncate_index]}\n```" f"... response truncated; {paste_text}", embed=embed ) @@ -212,6 +220,16 @@ async def func(): # (None,) -> Any await self._eval(ctx, code) +def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: + """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" + index = 0 + for _ in range(n): + index = string.find(substring, index+1) + if index == -1: + return None + return index + + def setup(bot: Bot) -> None: """Load the CodeEval cog.""" bot.add_cog(CodeEval(bot)) -- cgit v1.2.3 From c8f5f8597c8eb3cccf9cd7867fbc4777cc4b4f99 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 30 May 2020 02:42:38 +0200 Subject: Strip empty lines from int eval output. The output generates trailing newlines, which can cause the output to be uploaded to the paste service in cases where it's not needed, as discord will automatically remove those in messages. --- bot/cogs/eval.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index edb59d286..5b7469bdf 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -172,6 +172,8 @@ async def func(): # (None,) -> Any res = traceback.format_exc() out, embed = self._format(code, res) + out = out.rstrip("\n") # Strip empty lines from output + # Truncate output to max 15 lines or 1500 characters newline_truncate_index = find_nth_occurrence(out, "\n", 15) -- cgit v1.2.3 From 31726ecf6127f6f7dddcab3d16f4a40b8b990f6c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:30:00 +0200 Subject: Move general helper functions to submodule. --- bot/utils/__init__.py | 17 ++--------------- bot/utils/helpers.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 15 deletions(-) create mode 100644 bot/utils/helpers.py diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 2c8d57bd5..7c29a5981 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,25 +1,17 @@ import logging -from abc import ABCMeta from typing import Optional from aiohttp import ClientConnectorError, ClientSession -from discord.ext.commands import CogMeta from bot.constants import URLs +from bot.utils.helpers import CogABCMeta, pad_base64 from bot.utils.redis_cache import RedisCache log = logging.getLogger(__name__) FAILED_REQUEST_ATTEMPTS = 3 - -__all__ = ['RedisCache', 'CogABCMeta', "send_to_paste_service"] - - -class CogABCMeta(CogMeta, ABCMeta): - """Metaclass for ABCs meant to be implemented as Cogs.""" - - pass +__all__ = ['RedisCache', 'CogABCMeta', "pad_base64", "send_to_paste_service"] async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: @@ -64,8 +56,3 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e f"Got unexpected JSON response from paste service: {response_json}\n" f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) - - -def pad_base64(data: str) -> str: - """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" - return data + "=" * (-len(data) % 4) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py new file mode 100644 index 000000000..cfbf47753 --- /dev/null +++ b/bot/utils/helpers.py @@ -0,0 +1,12 @@ +from abc import ABCMeta + +from discord.ext.commands import CogMeta + + +class CogABCMeta(CogMeta, ABCMeta): + """Metaclass for ABCs meant to be implemented as Cogs.""" + + +def pad_base64(data: str) -> str: + """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" + return data + "=" * (-len(data) % 4) -- cgit v1.2.3 From 326beebe9b097731a39ecc9868e5e1f2bd762aae Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:33:42 +0200 Subject: Move `send_to_paste_service` to services submodule --- bot/utils/__init__.py | 55 +-------------------------------------------------- bot/utils/services.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 54 deletions(-) create mode 100644 bot/utils/services.py diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 7c29a5981..a950f3524 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,58 +1,5 @@ -import logging -from typing import Optional - -from aiohttp import ClientConnectorError, ClientSession - -from bot.constants import URLs from bot.utils.helpers import CogABCMeta, pad_base64 from bot.utils.redis_cache import RedisCache - -log = logging.getLogger(__name__) - -FAILED_REQUEST_ATTEMPTS = 3 +from bot.utils.services import send_to_paste_service __all__ = ['RedisCache', 'CogABCMeta', "pad_base64", "send_to_paste_service"] - - -async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: - """ - Upload `contents` to the paste service. - - `http_session` should be the current running ClientSession from aiohttp - `extension` is added to the output URL - - When an error occurs, `None` is returned, otherwise the generated URL with the suffix. - """ - extension = extension and f".{extension}" - log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") - paste_url = URLs.paste_service.format(key="documents") - for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): - try: - async with http_session.post(paste_url, data=contents) as response: - response_json = await response.json() - except ClientConnectorError: - log.warning( - f"Failed to connect to paste service at url {paste_url}, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - except Exception: - log.exception( - f"An unexpected error has occurred during handling of the request, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - - if "message" in response_json: - log.warning( - f"Paste service returned error {response_json['message']} with status code {response.status}, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - elif "key" in response_json: - log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") - return URLs.paste_service.format(key=response_json['key']) + extension - log.warning( - f"Got unexpected JSON response from paste service: {response_json}\n" - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) diff --git a/bot/utils/services.py b/bot/utils/services.py new file mode 100644 index 000000000..087b9f969 --- /dev/null +++ b/bot/utils/services.py @@ -0,0 +1,54 @@ +import logging +from typing import Optional + +from aiohttp import ClientConnectorError, ClientSession + +from bot.constants import URLs + +log = logging.getLogger(__name__) + +FAILED_REQUEST_ATTEMPTS = 3 + + +async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: + """ + Upload `contents` to the paste service. + + `http_session` should be the current running ClientSession from aiohttp + `extension` is added to the output URL + + When an error occurs, `None` is returned, otherwise the generated URL with the suffix. + """ + extension = extension and f".{extension}" + log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") + paste_url = URLs.paste_service.format(key="documents") + for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): + try: + async with http_session.post(paste_url, data=contents) as response: + response_json = await response.json() + except ClientConnectorError: + log.warning( + f"Failed to connect to paste service at url {paste_url}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + except Exception: + log.exception( + f"An unexpected error has occurred during handling of the request, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + + if "message" in response_json: + log.warning( + f"Paste service returned error {response_json['message']} with status code {response.status}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + elif "key" in response_json: + log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + return URLs.paste_service.format(key=response_json['key']) + extension + log.warning( + f"Got unexpected JSON response from paste service: {response_json}\n" + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) -- cgit v1.2.3 From 2d1877cfb70304ff8d6bd24059459fa514d49e71 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:37:28 +0200 Subject: Move `find_nth_occurrence` to utils helpers --- bot/cogs/eval.py | 12 +----------- bot/utils/__init__.py | 4 ++-- bot/utils/helpers.py | 11 +++++++++++ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 52f7ffca7..23e5998d8 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -15,7 +15,7 @@ from bot.bot import Bot from bot.constants import Roles from bot.decorators import with_role from bot.interpreter import Interpreter -from bot.utils import send_to_paste_service +from bot.utils import find_nth_occurrence, send_to_paste_service log = logging.getLogger(__name__) @@ -222,16 +222,6 @@ async def func(): # (None,) -> Any await self._eval(ctx, code) -def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: - """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" - index = 0 - for _ in range(n): - index = string.find(substring, index+1) - if index == -1: - return None - return index - - def setup(bot: Bot) -> None: """Load the CodeEval cog.""" bot.add_cog(CodeEval(bot)) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index a950f3524..3e93fcb06 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,5 @@ -from bot.utils.helpers import CogABCMeta, pad_base64 +from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64 from bot.utils.redis_cache import RedisCache from bot.utils.services import send_to_paste_service -__all__ = ['RedisCache', 'CogABCMeta', "pad_base64", "send_to_paste_service"] +__all__ = ['RedisCache', 'CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service'] diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index cfbf47753..d9b60af07 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -1,4 +1,5 @@ from abc import ABCMeta +from typing import Optional from discord.ext.commands import CogMeta @@ -7,6 +8,16 @@ class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" +def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: + """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" + index = 0 + for _ in range(n): + index = string.find(substring, index+1) + if index == -1: + return None + return index + + def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) -- cgit v1.2.3 From c115dcfb72e4d4a86b66bb84a72984705a2afcd4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:45:31 +0200 Subject: Change tests to work with the new file layout. 326beebe9b097731a39ecc9868e5e1f2bd762aae --- tests/bot/utils/test_init.py | 74 ---------------------------------------- tests/bot/utils/test_services.py | 74 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 74 deletions(-) delete mode 100644 tests/bot/utils/test_init.py create mode 100644 tests/bot/utils/test_services.py diff --git a/tests/bot/utils/test_init.py b/tests/bot/utils/test_init.py deleted file mode 100644 index f3a8f5939..000000000 --- a/tests/bot/utils/test_init.py +++ /dev/null @@ -1,74 +0,0 @@ -import logging -import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -from aiohttp import ClientConnectorError - -from bot.utils import FAILED_REQUEST_ATTEMPTS, send_to_paste_service - - -class PasteTests(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - self.http_session = MagicMock() - - @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") - async def test_url_and_sent_contents(self): - """Correct url was used and post was called with expected data.""" - response = MagicMock( - json=AsyncMock(return_value={"key": ""}) - ) - self.http_session.post().__aenter__.return_value = response - self.http_session.post.reset_mock() - await send_to_paste_service(self.http_session, "Content") - self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") - - @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") - async def test_paste_returns_correct_url_on_success(self): - """Url with specified extension is returned on successful requests.""" - key = "paste_key" - test_cases = ( - (f"https://paste_service.com/{key}.txt", "txt"), - (f"https://paste_service.com/{key}.py", "py"), - (f"https://paste_service.com/{key}", ""), - ) - response = MagicMock( - json=AsyncMock(return_value={"key": key}) - ) - self.http_session.post().__aenter__.return_value = response - - for expected_output, extension in test_cases: - with self.subTest(msg=f"Send contents with extension {repr(extension)}"): - self.assertEqual( - await send_to_paste_service(self.http_session, "", extension=extension), - expected_output - ) - - async def test_request_repeated_on_json_errors(self): - """Json with error message and invalid json are handled as errors and requests repeated.""" - test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) - self.http_session.post().__aenter__.return_value = response = MagicMock() - self.http_session.post.reset_mock() - - for error_json in test_cases: - with self.subTest(error_json=error_json): - response.json = AsyncMock(return_value=error_json) - result = await send_to_paste_service(self.http_session, "") - self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertIsNone(result) - - self.http_session.post.reset_mock() - - async def test_request_repeated_on_connection_errors(self): - """Requests are repeated in the case of connection errors.""" - self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) - result = await send_to_paste_service(self.http_session, "") - self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertIsNone(result) - - async def test_general_error_handled_and_request_repeated(self): - """All `Exception`s are handled, logged and request repeated.""" - self.http_session.post = MagicMock(side_effect=Exception) - result = await send_to_paste_service(self.http_session, "") - self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertLogs("bot.utils", logging.ERROR) - self.assertIsNone(result) diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py new file mode 100644 index 000000000..5e0855704 --- /dev/null +++ b/tests/bot/utils/test_services.py @@ -0,0 +1,74 @@ +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientConnectorError + +from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service + + +class PasteTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.http_session = MagicMock() + + @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") + async def test_url_and_sent_contents(self): + """Correct url was used and post was called with expected data.""" + response = MagicMock( + json=AsyncMock(return_value={"key": ""}) + ) + self.http_session.post().__aenter__.return_value = response + self.http_session.post.reset_mock() + await send_to_paste_service(self.http_session, "Content") + self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") + + @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") + async def test_paste_returns_correct_url_on_success(self): + """Url with specified extension is returned on successful requests.""" + key = "paste_key" + test_cases = ( + (f"https://paste_service.com/{key}.txt", "txt"), + (f"https://paste_service.com/{key}.py", "py"), + (f"https://paste_service.com/{key}", ""), + ) + response = MagicMock( + json=AsyncMock(return_value={"key": key}) + ) + self.http_session.post().__aenter__.return_value = response + + for expected_output, extension in test_cases: + with self.subTest(msg=f"Send contents with extension {repr(extension)}"): + self.assertEqual( + await send_to_paste_service(self.http_session, "", extension=extension), + expected_output + ) + + async def test_request_repeated_on_json_errors(self): + """Json with error message and invalid json are handled as errors and requests repeated.""" + test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) + self.http_session.post().__aenter__.return_value = response = MagicMock() + self.http_session.post.reset_mock() + + for error_json in test_cases: + with self.subTest(error_json=error_json): + response.json = AsyncMock(return_value=error_json) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + self.http_session.post.reset_mock() + + async def test_request_repeated_on_connection_errors(self): + """Requests are repeated in the case of connection errors.""" + self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + async def test_general_error_handled_and_request_repeated(self): + """All `Exception`s are handled, logged and request repeated.""" + self.http_session.post = MagicMock(side_effect=Exception) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertLogs("bot.utils", logging.ERROR) + self.assertIsNone(result) -- cgit v1.2.3