From 952f30f7cce351337c36655f4ff81e7e86d02b00 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 14:42:09 -0700 Subject: Add global bot instance Is **very** convenient when writing utility functions that rely on the bot's state, but aren't in cogs and therefore lack the typical way to access the instance. No more passing around of the instance as an arg! --- bot/__init__.py | 6 ++++++ bot/__main__.py | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/bot/__init__.py b/bot/__init__.py index 3ee70c4e9..0642b2c5d 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -5,12 +5,16 @@ import sys from functools import partial, partialmethod from logging import Logger, handlers from pathlib import Path +from typing import TYPE_CHECKING import coloredlogs from discord.ext import commands from bot.command import Command +if TYPE_CHECKING: + from bot.bot import Bot + TRACE_LEVEL = logging.TRACE = 5 logging.addLevelName(TRACE_LEVEL, "TRACE") @@ -76,3 +80,5 @@ if os.name == "nt": # Must be patched before any cogs are added. commands.command = partial(commands.command, cls=Command) commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) + +instance: "Bot" = None # Global Bot instance. diff --git a/bot/__main__.py b/bot/__main__.py index 367be1300..9d48c9092 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -9,6 +9,7 @@ from sentry_sdk.integrations.aiohttp import AioHttpIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.redis import RedisIntegration +import bot from bot import constants from bot.bot import Bot from bot.utils.extensions import EXTENSIONS @@ -54,7 +55,7 @@ intents.dm_reactions = False intents.invites = False intents.webhooks = False intents.integrations = False -bot = Bot( +bot.instance = Bot( redis_session=redis_session, loop=loop, command_prefix=when_mentioned_or(constants.Bot.prefix), @@ -71,6 +72,6 @@ if not constants.HelpChannels.enable: extensions.remove("bot.exts.help_channels") for extension in extensions: - bot.load_extension(extension) + bot.instance.load_extension(extension) -bot.run(constants.Bot.token) +bot.instance.run(constants.Bot.token) -- cgit v1.2.3 From e059c32d10997e22b508c04031c19999f3185f7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 14:53:18 -0700 Subject: Use global bot instance in send_to_paste_service --- bot/exts/utils/internal.py | 2 +- bot/exts/utils/snekbox.py | 2 +- bot/utils/services.py | 8 ++++---- tests/bot/exts/utils/test_snekbox.py | 4 +--- tests/bot/utils/test_services.py | 39 +++++++++++++++++++----------------- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 1b4900f42..a6bc60026 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -195,7 +195,7 @@ async def func(): # (None,) -> Any truncate_index = newline_truncate_index if len(out) > truncate_index: - paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + paste_link = await send_to_paste_service(out, extension="py") if paste_link is not None: paste_text = f"full contents at {paste_link}" else: diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 59a27a2be..e727be39e 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -72,7 +72,7 @@ class Snekbox(Cog): if len(output) > MAX_PASTE_LEN: log.info("Full output is too long to upload") return "too long to upload" - return await send_to_paste_service(self.bot.http_session, output, extension="txt") + return await send_to_paste_service(output, extension="txt") @staticmethod def prepare_input(code: str) -> str: diff --git a/bot/utils/services.py b/bot/utils/services.py index 087b9f969..5949c9e48 100644 --- a/bot/utils/services.py +++ b/bot/utils/services.py @@ -1,8 +1,9 @@ import logging from typing import Optional -from aiohttp import ClientConnectorError, ClientSession +from aiohttp import ClientConnectorError +import bot from bot.constants import URLs log = logging.getLogger(__name__) @@ -10,11 +11,10 @@ log = logging.getLogger(__name__) FAILED_REQUEST_ATTEMPTS = 3 -async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: +async def send_to_paste_service(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. @@ -24,7 +24,7 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e 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: + async with bot.instance.http_session.post(paste_url, data=contents) as response: response_json = await response.json() except ClientConnectorError: log.warning( diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py index 6601fad2c..9d3e07e7c 100644 --- a/tests/bot/exts/utils/test_snekbox.py +++ b/tests/bot/exts/utils/test_snekbox.py @@ -42,9 +42,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): async def test_upload_output(self, mock_paste_util): """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" await self.cog.upload_output("Test output.") - mock_paste_util.assert_called_once_with( - self.bot.http_session, "Test output.", extension="txt" - ) + mock_paste_util.assert_called_once_with("Test output.", extension="txt") def test_prepare_input(self): cases = ( diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py index 5e0855704..1b48f6560 100644 --- a/tests/bot/utils/test_services.py +++ b/tests/bot/utils/test_services.py @@ -5,11 +5,14 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch from aiohttp import ClientConnectorError from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service +from tests.helpers import MockBot class PasteTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: - self.http_session = MagicMock() + patcher = patch("bot.instance", new=MockBot()) + self.bot = patcher.start() + self.addCleanup(patcher.stop) @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") async def test_url_and_sent_contents(self): @@ -17,10 +20,10 @@ class PasteTests(unittest.IsolatedAsyncioTestCase): 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") + self.bot.http_session.post.return_value.__aenter__.return_value = response + self.bot.http_session.post.reset_mock() + await send_to_paste_service("Content") + self.bot.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): @@ -34,41 +37,41 @@ class PasteTests(unittest.IsolatedAsyncioTestCase): response = MagicMock( json=AsyncMock(return_value={"key": key}) ) - self.http_session.post().__aenter__.return_value = response + self.bot.http_session.post.return_value.__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), + await send_to_paste_service("", 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() + self.bot.http_session.post.return_value.__aenter__.return_value = response = MagicMock() + self.bot.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) + result = await send_to_paste_service("") + self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) self.assertIsNone(result) - self.http_session.post.reset_mock() + self.bot.http_session.post.reset_mock() async def test_request_repeated_on_connection_errors(self): """Requests are repeated in the case of connection errors.""" - self.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.bot.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) + result = await send_to_paste_service("") + self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) self.assertIsNone(result) async def test_general_error_handled_and_request_repeated(self): """All `Exception`s are handled, logged and request repeated.""" - self.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.bot.http_session.post = MagicMock(side_effect=Exception) + result = await send_to_paste_service("") + self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) self.assertLogs("bot.utils", logging.ERROR) self.assertIsNone(result) -- cgit v1.2.3 From a75e306504a0372d987639966844e0827410a317 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 14:56:24 -0700 Subject: Use global bot instance in wait_for_deletion --- bot/exts/info/codeblock/_cog.py | 2 +- bot/exts/info/doc.py | 2 +- bot/exts/info/help.py | 6 +++--- bot/exts/info/tags.py | 2 -- bot/exts/utils/snekbox.py | 2 +- bot/utils/messages.py | 4 ++-- 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py index 1e0feab0d..9094d9d15 100644 --- a/bot/exts/info/codeblock/_cog.py +++ b/bot/exts/info/codeblock/_cog.py @@ -114,7 +114,7 @@ class CodeBlockCog(Cog, name="Code Block"): bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id - self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,), self.bot)) + self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,))) # Increase amount of codeblock correction in stats self.bot.stats.incr("codeblock_corrections") diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py index c16a99225..aba7f7e48 100644 --- a/bot/exts/info/doc.py +++ b/bot/exts/info/doc.py @@ -392,7 +392,7 @@ class Doc(commands.Cog): await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: msg = await ctx.send(embed=doc_embed) - await wait_for_deletion(msg, (ctx.author.id,), client=self.bot) + await wait_for_deletion(msg, (ctx.author.id,)) @docs_group.command(name='set', aliases=('s',)) @commands.has_any_role(*MODERATION_ROLES) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 599c5d5c0..461ff82fd 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -186,7 +186,7 @@ class CustomHelpCommand(HelpCommand): """Send help for a single command.""" embed = await self.command_formatting(command) message = await self.context.send(embed=embed) - await wait_for_deletion(message, (self.context.author.id,), self.context.bot) + await wait_for_deletion(message, (self.context.author.id,)) @staticmethod def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: @@ -225,7 +225,7 @@ class CustomHelpCommand(HelpCommand): embed.description += f"\n**Subcommands:**\n{command_details}" message = await self.context.send(embed=embed) - await wait_for_deletion(message, (self.context.author.id,), self.context.bot) + await wait_for_deletion(message, (self.context.author.id,)) async def send_cog_help(self, cog: Cog) -> None: """Send help for a cog.""" @@ -241,7 +241,7 @@ class CustomHelpCommand(HelpCommand): embed.description += f"\n\n**Commands:**\n{command_details}" message = await self.context.send(embed=embed) - await wait_for_deletion(message, (self.context.author.id,), self.context.bot) + await wait_for_deletion(message, (self.context.author.id,)) @staticmethod def _category_key(command: Command) -> str: diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index ae95ac1ef..8f15f932b 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -236,7 +236,6 @@ class Tags(Cog): await wait_for_deletion( await ctx.send(embed=Embed.from_dict(tag['embed'])), [ctx.author.id], - self.bot ) elif founds and len(tag_name) >= 3: await wait_for_deletion( @@ -247,7 +246,6 @@ class Tags(Cog): ) ), [ctx.author.id], - self.bot ) else: diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index e727be39e..bde1684d8 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -212,7 +212,7 @@ class Snekbox(Cog): response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: response = await ctx.send(msg) - self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,), ctx.bot)) + self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,))) log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") return response diff --git a/bot/utils/messages.py b/bot/utils/messages.py index b6c7cab50..42bde358d 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -10,6 +10,7 @@ import discord from discord.errors import HTTPException from discord.ext.commands import Context +import bot from bot.constants import Emojis, NEGATIVE_REPLIES log = logging.getLogger(__name__) @@ -18,7 +19,6 @@ log = logging.getLogger(__name__) async def wait_for_deletion( message: discord.Message, user_ids: Sequence[discord.abc.Snowflake], - client: discord.Client, deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, attach_emojis: bool = True, @@ -49,7 +49,7 @@ async def wait_for_deletion( ) with contextlib.suppress(asyncio.TimeoutError): - await client.wait_for('reaction_add', check=check, timeout=timeout) + await bot.instance.wait_for('reaction_add', check=check, timeout=timeout) await message.delete() -- cgit v1.2.3 From 14e753209800716559ed0f8724ba9cde37ad09e6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 14:58:45 -0700 Subject: Use global bot instance in try_get_channel --- bot/exts/help_channels.py | 11 ++++------- bot/utils/channel.py | 7 ++++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index 062d4fcfe..f5a8b251b 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -380,16 +380,13 @@ class HelpChannels(commands.Cog): try: self.available_category = await channel_utils.try_get_channel( - constants.Categories.help_available, - self.bot + constants.Categories.help_available ) self.in_use_category = await channel_utils.try_get_channel( - constants.Categories.help_in_use, - self.bot + constants.Categories.help_in_use ) self.dormant_category = await channel_utils.try_get_channel( - constants.Categories.help_dormant, - self.bot + constants.Categories.help_dormant ) except discord.HTTPException: log.exception("Failed to get a category; cog will be removed") @@ -500,7 +497,7 @@ class HelpChannels(commands.Cog): options should be avoided, as it may interfere with the category move we perform. """ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await channel_utils.try_get_channel(category_id, self.bot) + category = await channel_utils.try_get_channel(category_id) payload = [{"id": c.id, "position": c.position} for c in category.channels] diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 851f9e1fe..d9d1b4b86 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -2,6 +2,7 @@ import logging import discord +import bot from bot.constants import Categories log = logging.getLogger(__name__) @@ -20,14 +21,14 @@ def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: return getattr(channel, "category_id", None) == category_id -async def try_get_channel(channel_id: int, client: discord.Client) -> discord.abc.GuildChannel: +async def try_get_channel(channel_id: int) -> discord.abc.GuildChannel: """Attempt to get or fetch a channel and return it.""" log.trace(f"Getting the channel {channel_id}.") - channel = client.get_channel(channel_id) + channel = bot.instance.get_channel(channel_id) if not channel: log.debug(f"Channel {channel_id} is not in cache; fetching from API.") - channel = await client.fetch_channel(channel_id) + channel = await bot.instance.fetch_channel(channel_id) log.trace(f"Channel #{channel} ({channel_id}) retrieved.") return channel -- cgit v1.2.3 From 09d559fe60f450ce1b3e11b341971df1d12b1562 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 15:11:14 -0700 Subject: Use global bot instance in Interpreter --- bot/exts/utils/internal.py | 2 +- bot/interpreter.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index a6bc60026..3521c8fd4 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -30,7 +30,7 @@ class Internal(Cog): self.ln = 0 self.stdout = StringIO() - self.interpreter = Interpreter(bot) + self.interpreter = Interpreter() self.socket_since = datetime.utcnow() self.socket_event_total = 0 diff --git a/bot/interpreter.py b/bot/interpreter.py index 8b7268746..b58f7a6b0 100644 --- a/bot/interpreter.py +++ b/bot/interpreter.py @@ -4,7 +4,7 @@ from typing import Any from discord.ext.commands import Context -from bot.bot import Bot +import bot CODE_TEMPLATE = """ async def _func(): @@ -21,8 +21,8 @@ class Interpreter(InteractiveInterpreter): write_callable = None - def __init__(self, bot: Bot): - locals_ = {"bot": bot} + def __init__(self): + locals_ = {"bot": bot.instance} super().__init__(locals_) async def run(self, code: str, ctx: Context, io: StringIO, *args, **kwargs) -> Any: -- cgit v1.2.3 From f3f4b2acc2500e37ed7d1007c40a125b3442c5f0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 15:51:04 -0700 Subject: Use global bot instance in syncers They're pretty close to being fully static classes, but it's difficult to make the name attribute a static abstract property. --- bot/exts/backend/sync/_cog.py | 4 +-- bot/exts/backend/sync/_syncers.py | 42 +++++++++++++++++-------------- tests/bot/exts/backend/sync/test_base.py | 12 ++++----- tests/bot/exts/backend/sync/test_cog.py | 20 +++++++-------- tests/bot/exts/backend/sync/test_roles.py | 14 ++++++++--- tests/bot/exts/backend/sync/test_users.py | 15 ++++++++--- 6 files changed, 62 insertions(+), 45 deletions(-) diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py index 6e85e2b7d..b71ed3e69 100644 --- a/bot/exts/backend/sync/_cog.py +++ b/bot/exts/backend/sync/_cog.py @@ -18,8 +18,8 @@ class Sync(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - self.role_syncer = _syncers.RoleSyncer(self.bot) - self.user_syncer = _syncers.UserSyncer(self.bot) + self.role_syncer = _syncers.RoleSyncer() + self.user_syncer = _syncers.UserSyncer() self.bot.loop.create_task(self.sync_guild()) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 38468c2b1..bdd76806b 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -6,8 +6,8 @@ from collections import namedtuple from discord import Guild from discord.ext.commands import Context +import bot from bot.api import ResponseCodeError -from bot.bot import Bot log = logging.getLogger(__name__) @@ -20,22 +20,21 @@ _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) class Syncer(abc.ABC): """Base class for synchronising the database with objects in the Discord cache.""" - def __init__(self, bot: Bot) -> None: - self.bot = bot - @property @abc.abstractmethod def name(self) -> str: """The name of the syncer; used in output messages and logging.""" raise NotImplementedError # pragma: no cover + @staticmethod @abc.abstractmethod - async def _get_diff(self, guild: Guild) -> _Diff: + async def _get_diff(guild: Guild) -> _Diff: """Return the difference between the cache of `guild` and the database.""" raise NotImplementedError # pragma: no cover + @staticmethod @abc.abstractmethod - async def _sync(self, diff: _Diff) -> None: + async def _sync(diff: _Diff) -> None: """Perform the API calls for synchronisation.""" raise NotImplementedError # pragma: no cover @@ -78,10 +77,11 @@ class RoleSyncer(Syncer): name = "role" - async def _get_diff(self, guild: Guild) -> _Diff: + @staticmethod + async def _get_diff(guild: Guild) -> _Diff: """Return the difference of roles between the cache of `guild` and the database.""" log.trace("Getting the diff for roles.") - roles = await self.bot.api_client.get('bot/roles') + roles = await bot.instance.api_client.get('bot/roles') # Pack DB roles and guild roles into one common, hashable format. # They're hashable so that they're easily comparable with sets later. @@ -110,19 +110,20 @@ class RoleSyncer(Syncer): return _Diff(roles_to_create, roles_to_update, roles_to_delete) - async def _sync(self, diff: _Diff) -> None: + @staticmethod + async def _sync(diff: _Diff) -> None: """Synchronise the database with the role cache of `guild`.""" log.trace("Syncing created roles...") for role in diff.created: - await self.bot.api_client.post('bot/roles', json=role._asdict()) + await bot.instance.api_client.post('bot/roles', json=role._asdict()) log.trace("Syncing updated roles...") for role in diff.updated: - await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) + await bot.instance.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) log.trace("Syncing deleted roles...") for role in diff.deleted: - await self.bot.api_client.delete(f'bot/roles/{role.id}') + await bot.instance.api_client.delete(f'bot/roles/{role.id}') class UserSyncer(Syncer): @@ -130,7 +131,8 @@ class UserSyncer(Syncer): name = "user" - async def _get_diff(self, guild: Guild) -> _Diff: + @staticmethod + async def _get_diff(guild: Guild) -> _Diff: """Return the difference of users between the cache of `guild` and the database.""" log.trace("Getting the diff for users.") @@ -138,7 +140,7 @@ class UserSyncer(Syncer): users_to_update = [] seen_guild_users = set() - async for db_user in self._get_users(): + async for db_user in UserSyncer._get_users(): # Store user fields which are to be updated. updated_fields = {} @@ -185,24 +187,26 @@ class UserSyncer(Syncer): return _Diff(users_to_create, users_to_update, None) - async def _get_users(self) -> t.AsyncIterable: + @staticmethod + async def _get_users() -> t.AsyncIterable: """GET users from database.""" query_params = { "page": 1 } while query_params["page"]: - res = await self.bot.api_client.get("bot/users", params=query_params) + res = await bot.instance.api_client.get("bot/users", params=query_params) for user in res["results"]: yield user query_params["page"] = res["next_page_no"] - async def _sync(self, diff: _Diff) -> None: + @staticmethod + async def _sync(diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") if diff.created: - await self.bot.api_client.post("bot/users", json=diff.created) + await bot.instance.api_client.post("bot/users", json=diff.created) log.trace("Syncing updated users...") if diff.updated: - await self.bot.api_client.patch("bot/users/bulk_patch", json=diff.updated) + await bot.instance.api_client.patch("bot/users/bulk_patch", json=diff.updated) diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py index 4953550f9..157d42452 100644 --- a/tests/bot/exts/backend/sync/test_base.py +++ b/tests/bot/exts/backend/sync/test_base.py @@ -18,21 +18,21 @@ class TestSyncer(Syncer): class SyncerBaseTests(unittest.TestCase): """Tests for the syncer base class.""" - def setUp(self): - self.bot = helpers.MockBot() - def test_instantiation_fails_without_abstract_methods(self): """The class must have abstract methods implemented.""" with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): - Syncer(self.bot) + Syncer() 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) + patcher = mock.patch("bot.instance", new=helpers.MockBot(user=helpers.MockMember(bot=True))) + self.bot = patcher.start() + self.addCleanup(patcher.stop) + + self.syncer = TestSyncer() self.guild = helpers.MockGuild() # Make sure `_get_diff` returns a MagicMock, not an AsyncMock diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py index 063a82754..1e1883558 100644 --- a/tests/bot/exts/backend/sync/test_cog.py +++ b/tests/bot/exts/backend/sync/test_cog.py @@ -29,24 +29,24 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = helpers.MockBot() - self.role_syncer_patcher = mock.patch( + role_syncer_patcher = mock.patch( "bot.exts.backend.sync._syncers.RoleSyncer", autospec=Syncer, spec_set=True ) - self.user_syncer_patcher = mock.patch( + user_syncer_patcher = mock.patch( "bot.exts.backend.sync._syncers.UserSyncer", autospec=Syncer, spec_set=True ) - self.RoleSyncer = self.role_syncer_patcher.start() - self.UserSyncer = self.user_syncer_patcher.start() - self.cog = Sync(self.bot) + self.RoleSyncer = role_syncer_patcher.start() + self.UserSyncer = user_syncer_patcher.start() - def tearDown(self): - self.role_syncer_patcher.stop() - self.user_syncer_patcher.stop() + self.addCleanup(role_syncer_patcher.stop) + self.addCleanup(user_syncer_patcher.stop) + + self.cog = Sync(self.bot) @staticmethod def response_error(status: int) -> ResponseCodeError: @@ -73,8 +73,8 @@ class SyncCogTests(SyncCogTestCase): Sync(self.bot) - self.RoleSyncer.assert_called_once_with(self.bot) - self.UserSyncer.assert_called_once_with(self.bot) + self.RoleSyncer.assert_called_once_with() + self.UserSyncer.assert_called_once_with() sync_guild.assert_called_once_with() self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) diff --git a/tests/bot/exts/backend/sync/test_roles.py b/tests/bot/exts/backend/sync/test_roles.py index 7b9f40cad..fb63a4ae0 100644 --- a/tests/bot/exts/backend/sync/test_roles.py +++ b/tests/bot/exts/backend/sync/test_roles.py @@ -22,8 +22,11 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Tests for determining differences between roles in the DB and roles in the Guild cache.""" def setUp(self): - self.bot = helpers.MockBot() - self.syncer = RoleSyncer(self.bot) + patcher = mock.patch("bot.instance", new=helpers.MockBot()) + self.bot = patcher.start() + self.addCleanup(patcher.stop) + + self.syncer = RoleSyncer() @staticmethod def get_guild(*roles): @@ -108,8 +111,11 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for the API requests that sync roles.""" def setUp(self): - self.bot = helpers.MockBot() - self.syncer = RoleSyncer(self.bot) + patcher = mock.patch("bot.instance", new=helpers.MockBot()) + self.bot = patcher.start() + self.addCleanup(patcher.stop) + + self.syncer = RoleSyncer() async def test_sync_created_roles(self): """Only POST requests should be made with the correct payload.""" diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index 9f380a15d..9f28d0162 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -1,4 +1,5 @@ import unittest +from unittest import mock from bot.exts.backend.sync._syncers import UserSyncer, _Diff from tests import helpers @@ -19,8 +20,11 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Tests for determining differences between users in the DB and users in the Guild cache.""" def setUp(self): - self.bot = helpers.MockBot() - self.syncer = UserSyncer(self.bot) + patcher = mock.patch("bot.instance", new=helpers.MockBot()) + self.bot = patcher.start() + self.addCleanup(patcher.stop) + + self.syncer = UserSyncer() @staticmethod def get_guild(*members): @@ -186,8 +190,11 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for the API requests that sync users.""" def setUp(self): - self.bot = helpers.MockBot() - self.syncer = UserSyncer(self.bot) + patcher = mock.patch("bot.instance", new=helpers.MockBot()) + self.bot = patcher.start() + self.addCleanup(patcher.stop) + + self.syncer = UserSyncer() async def test_sync_created_users(self): """Only POST requests should be made with the correct payload.""" -- cgit v1.2.3 From cc3805e0ca378ccaa3d025947c0982d0c8cb4e9f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 17 Oct 2020 13:33:55 -0700 Subject: Syncers: make functions static The classes no longer hold any state since they can use the global bot instance. --- bot/exts/backend/sync/_cog.py | 9 +++------ bot/exts/backend/sync/_syncers.py | 24 ++++++++++++++---------- tests/bot/exts/backend/sync/test_base.py | 21 +++++++-------------- tests/bot/exts/backend/sync/test_cog.py | 18 ++++++++---------- tests/bot/exts/backend/sync/test_roles.py | 20 ++++++++------------ tests/bot/exts/backend/sync/test_users.py | 22 +++++++++------------- 6 files changed, 49 insertions(+), 65 deletions(-) diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py index b71ed3e69..48d2b6f02 100644 --- a/bot/exts/backend/sync/_cog.py +++ b/bot/exts/backend/sync/_cog.py @@ -18,9 +18,6 @@ class Sync(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - self.role_syncer = _syncers.RoleSyncer() - self.user_syncer = _syncers.UserSyncer() - self.bot.loop.create_task(self.sync_guild()) async def sync_guild(self) -> None: @@ -31,7 +28,7 @@ class Sync(Cog): if guild is None: return - for syncer in (self.role_syncer, self.user_syncer): + for syncer in (_syncers.RoleSyncer, _syncers.UserSyncer): await syncer.sync(guild) async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: @@ -171,10 +168,10 @@ class Sync(Cog): @commands.has_permissions(administrator=True) async def sync_roles_command(self, ctx: Context) -> None: """Manually synchronise the guild's roles with the roles on the site.""" - await self.role_syncer.sync(ctx.guild, ctx) + await _syncers.RoleSyncer.sync(ctx.guild, ctx) @sync_group.command(name='users') @commands.has_permissions(administrator=True) async def sync_users_command(self, ctx: Context) -> None: """Manually synchronise the guild's users with the users on the site.""" - await self.user_syncer.sync(ctx.guild, ctx) + await _syncers.UserSyncer.sync(ctx.guild, ctx) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index bdd76806b..2eb9f9971 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -17,12 +17,15 @@ _Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) +# Implementation of static abstract methods are not enforced if the subclass is never instantiated. +# However, methods are kept abstract to at least symbolise that they should be abstract. class Syncer(abc.ABC): """Base class for synchronising the database with objects in the Discord cache.""" + @staticmethod @property @abc.abstractmethod - def name(self) -> str: + def name() -> str: """The name of the syncer; used in output messages and logging.""" raise NotImplementedError # pragma: no cover @@ -38,35 +41,36 @@ class Syncer(abc.ABC): """Perform the API calls for synchronisation.""" raise NotImplementedError # pragma: no cover - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: + @classmethod + async def sync(cls, guild: Guild, ctx: t.Optional[Context] = None) -> None: """ Synchronise the database with the cache of `guild`. If `ctx` is given, send a message with the results. """ - log.info(f"Starting {self.name} syncer.") + log.info(f"Starting {cls.name} syncer.") if ctx: - message = await ctx.send(f"📊 Synchronising {self.name}s.") + message = await ctx.send(f"📊 Synchronising {cls.name}s.") else: message = None - diff = await self._get_diff(guild) + diff = await cls._get_diff(guild) try: - await self._sync(diff) + await cls._sync(diff) except ResponseCodeError as e: - log.exception(f"{self.name} syncer failed!") + log.exception(f"{cls.name} syncer failed!") # Don't show response text because it's probably some really long HTML. results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" - content = f":x: Synchronisation of {self.name}s failed: {results}" + content = f":x: Synchronisation of {cls.name}s failed: {results}" else: diff_dict = diff._asdict() results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None) results = ", ".join(results) - log.info(f"{self.name} syncer finished: {results}.") - content = f":ok_hand: Synchronisation of {self.name}s complete: {results}" + log.info(f"{cls.name} syncer finished: {results}.") + content = f":ok_hand: Synchronisation of {cls.name}s complete: {results}" if message: await message.edit(content=content) diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py index 157d42452..3ad9db9c3 100644 --- a/tests/bot/exts/backend/sync/test_base.py +++ b/tests/bot/exts/backend/sync/test_base.py @@ -15,15 +15,6 @@ class TestSyncer(Syncer): _sync = mock.AsyncMock() -class SyncerBaseTests(unittest.TestCase): - """Tests for the syncer base class.""" - - def test_instantiation_fails_without_abstract_methods(self): - """The class must have abstract methods implemented.""" - with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): - Syncer() - - class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for main function orchestrating the sync.""" @@ -32,11 +23,13 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): self.bot = patcher.start() self.addCleanup(patcher.stop) - self.syncer = TestSyncer() self.guild = helpers.MockGuild() + TestSyncer._get_diff.reset_mock(return_value=True, side_effect=True) + TestSyncer._sync.reset_mock(return_value=True, side_effect=True) + # Make sure `_get_diff` returns a MagicMock, not an AsyncMock - self.syncer._get_diff.return_value = mock.MagicMock() + TestSyncer._get_diff.return_value = mock.MagicMock() async def test_sync_message_edited(self): """The message should be edited if one was sent, even if the sync has an API error.""" @@ -48,11 +41,11 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): 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 + TestSyncer._sync.side_effect = side_effect ctx = helpers.MockContext() ctx.send.return_value = message - await self.syncer.sync(self.guild, ctx) + await TestSyncer.sync(self.guild, ctx) if should_edit: message.edit.assert_called_once() @@ -67,7 +60,7 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): for ctx, message in subtests: with self.subTest(ctx=ctx, message=message): - await self.syncer.sync(self.guild, ctx) + await TestSyncer.sync(self.guild, ctx) if ctx is not None: ctx.send.assert_called_once() diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py index 1e1883558..22a07313e 100644 --- a/tests/bot/exts/backend/sync/test_cog.py +++ b/tests/bot/exts/backend/sync/test_cog.py @@ -73,8 +73,6 @@ class SyncCogTests(SyncCogTestCase): Sync(self.bot) - self.RoleSyncer.assert_called_once_with() - self.UserSyncer.assert_called_once_with() sync_guild.assert_called_once_with() self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) @@ -83,8 +81,8 @@ class SyncCogTests(SyncCogTestCase): for guild in (helpers.MockGuild(), None): with self.subTest(guild=guild): self.bot.reset_mock() - self.cog.role_syncer.reset_mock() - self.cog.user_syncer.reset_mock() + self.RoleSyncer.reset_mock() + self.UserSyncer.reset_mock() self.bot.get_guild = mock.MagicMock(return_value=guild) @@ -94,11 +92,11 @@ class SyncCogTests(SyncCogTestCase): self.bot.get_guild.assert_called_once_with(constants.Guild.id) if guild is None: - self.cog.role_syncer.sync.assert_not_called() - self.cog.user_syncer.sync.assert_not_called() + self.RoleSyncer.sync.assert_not_called() + self.UserSyncer.sync.assert_not_called() else: - self.cog.role_syncer.sync.assert_called_once_with(guild) - self.cog.user_syncer.sync.assert_called_once_with(guild) + self.RoleSyncer.sync.assert_called_once_with(guild) + self.UserSyncer.sync.assert_called_once_with(guild) async def patch_user_helper(self, side_effect: BaseException) -> None: """Helper to set a side effect for bot.api_client.patch and then assert it is called.""" @@ -394,14 +392,14 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): ctx = helpers.MockContext() await self.cog.sync_roles_command(self.cog, ctx) - self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) + self.RoleSyncer.sync.assert_called_once_with(ctx.guild, ctx) async def test_sync_users_command(self): """sync() should be called on the UserSyncer.""" ctx = helpers.MockContext() await self.cog.sync_users_command(self.cog, ctx) - self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) + self.UserSyncer.sync.assert_called_once_with(ctx.guild, ctx) async def test_commands_require_admin(self): """The sync commands should only run if the author has the administrator permission.""" diff --git a/tests/bot/exts/backend/sync/test_roles.py b/tests/bot/exts/backend/sync/test_roles.py index fb63a4ae0..541074336 100644 --- a/tests/bot/exts/backend/sync/test_roles.py +++ b/tests/bot/exts/backend/sync/test_roles.py @@ -26,8 +26,6 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.bot = patcher.start() self.addCleanup(patcher.stop) - self.syncer = RoleSyncer() - @staticmethod def get_guild(*roles): """Fixture to return a guild object with the given roles.""" @@ -47,7 +45,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.get.return_value = [fake_role()] guild = self.get_guild(fake_role()) - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await RoleSyncer._get_diff(guild) expected_diff = (set(), set(), set()) self.assertEqual(actual_diff, expected_diff) @@ -59,7 +57,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()] guild = self.get_guild(updated_role, fake_role()) - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await RoleSyncer._get_diff(guild) expected_diff = (set(), {_Role(**updated_role)}, set()) self.assertEqual(actual_diff, expected_diff) @@ -71,7 +69,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.get.return_value = [fake_role()] guild = self.get_guild(fake_role(), new_role) - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await RoleSyncer._get_diff(guild) expected_diff = ({_Role(**new_role)}, set(), set()) self.assertEqual(actual_diff, expected_diff) @@ -83,7 +81,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.get.return_value = [fake_role(), deleted_role] guild = self.get_guild(fake_role()) - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await RoleSyncer._get_diff(guild) expected_diff = (set(), set(), {_Role(**deleted_role)}) self.assertEqual(actual_diff, expected_diff) @@ -101,7 +99,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): ] guild = self.get_guild(fake_role(), new, updated) - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await RoleSyncer._get_diff(guild) expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) self.assertEqual(actual_diff, expected_diff) @@ -115,15 +113,13 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): self.bot = patcher.start() self.addCleanup(patcher.stop) - self.syncer = RoleSyncer() - 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)] role_tuples = {_Role(**role) for role in roles} diff = _Diff(role_tuples, set(), set()) - await self.syncer._sync(diff) + await RoleSyncer._sync(diff) calls = [mock.call("bot/roles", json=role) for role in roles] self.bot.api_client.post.assert_has_calls(calls, any_order=True) @@ -138,7 +134,7 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): role_tuples = {_Role(**role) for role in roles} diff = _Diff(set(), role_tuples, set()) - await self.syncer._sync(diff) + await RoleSyncer._sync(diff) calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles] self.bot.api_client.put.assert_has_calls(calls, any_order=True) @@ -153,7 +149,7 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): role_tuples = {_Role(**role) for role in roles} diff = _Diff(set(), set(), role_tuples) - await self.syncer._sync(diff) + await RoleSyncer._sync(diff) calls = [mock.call(f"bot/roles/{role['id']}") for role in roles] self.bot.api_client.delete.assert_has_calls(calls, any_order=True) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index 9f28d0162..61673e1bb 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -24,8 +24,6 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.bot = patcher.start() self.addCleanup(patcher.stop) - self.syncer = UserSyncer() - @staticmethod def get_guild(*members): """Fixture to return a guild object with the given members.""" @@ -61,7 +59,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): } guild = self.get_guild() - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([], [], None) self.assertEqual(actual_diff, expected_diff) @@ -77,7 +75,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): guild = self.get_guild(fake_user()) guild.get_member.return_value = self.get_mock_member(fake_user()) - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([], [], None) self.assertEqual(actual_diff, expected_diff) @@ -98,7 +96,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.get_mock_member(fake_user()) ] - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([], [{"id": 99, "name": "new"}], None) self.assertEqual(actual_diff, expected_diff) @@ -118,7 +116,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.get_mock_member(fake_user()), self.get_mock_member(new_user) ] - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([new_user], [], None) self.assertEqual(actual_diff, expected_diff) @@ -137,7 +135,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): None ] - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([], [{"id": 63, "in_guild": False}], None) self.assertEqual(actual_diff, expected_diff) @@ -161,7 +159,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): None ] - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None) self.assertEqual(actual_diff, expected_diff) @@ -180,7 +178,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): None ] - actual_diff = await self.syncer._get_diff(guild) + actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([], [], None) self.assertEqual(actual_diff, expected_diff) @@ -194,14 +192,12 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): self.bot = patcher.start() self.addCleanup(patcher.stop) - self.syncer = UserSyncer() - 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)] diff = _Diff(users, [], None) - await self.syncer._sync(diff) + await UserSyncer._sync(diff) self.bot.api_client.post.assert_called_once_with("bot/users", json=diff.created) @@ -213,7 +209,7 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): users = [fake_user(id=111), fake_user(id=222)] diff = _Diff([], users, None) - await self.syncer._sync(diff) + await UserSyncer._sync(diff) self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=diff.updated) -- cgit v1.2.3 From 2bfe55cf484e9e2d6065ea693590d45653820821 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 17 Oct 2020 19:10:15 -0700 Subject: Move logging set up to a separate module --- .gitignore | 1 + bot/__init__.py | 64 ++--------------------------------------- bot/__main__.py | 19 ------------- bot/bot.py | 4 +-- bot/constants.py | 2 +- bot/log.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 83 deletions(-) create mode 100644 bot/log.py diff --git a/.gitignore b/.gitignore index 2074887ad..9186dbe06 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,7 @@ ENV/ # Logfiles log.* *.log.* +!log.py # Custom user configuration config.yml diff --git a/bot/__init__.py b/bot/__init__.py index 0642b2c5d..d2fd107a0 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -1,81 +1,23 @@ import asyncio -import logging import os -import sys from functools import partial, partialmethod -from logging import Logger, handlers -from pathlib import Path from typing import TYPE_CHECKING -import coloredlogs from discord.ext import commands +from bot import log from bot.command import Command if TYPE_CHECKING: from bot.bot import Bot -TRACE_LEVEL = logging.TRACE = 5 -logging.addLevelName(TRACE_LEVEL, "TRACE") - - -def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: - """ - Log 'msg % args' with severity 'TRACE'. - - To pass exception information, use the keyword argument exc_info with - a true value, e.g. - - logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) - """ - if self.isEnabledFor(TRACE_LEVEL): - self._log(TRACE_LEVEL, msg, args, **kwargs) - - -Logger.trace = monkeypatch_trace - -DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local") - -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) -file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") -file_handler.setFormatter(log_format) - -root_log = logging.getLogger() -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("chardet").setLevel(logging.WARNING) -logging.getLogger(__name__) - +log.setup() +log.setup_sentry() # On Windows, the selector event loop is required for aiodns. if os.name == "nt": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - # Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. # Must be patched before any cogs are added. commands.command = partial(commands.command, cls=Command) diff --git a/bot/__main__.py b/bot/__main__.py index 9d48c9092..f3204c18a 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,33 +1,14 @@ import asyncio -import logging import discord -import sentry_sdk from async_rediscache import RedisSession from discord.ext.commands import when_mentioned_or -from sentry_sdk.integrations.aiohttp import AioHttpIntegration -from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.integrations.redis import RedisIntegration import bot from bot import constants from bot.bot import Bot from bot.utils.extensions import EXTENSIONS -# Set up Sentry. -sentry_logging = LoggingIntegration( - level=logging.DEBUG, - event_level=logging.WARNING -) - -sentry_sdk.init( - dsn=constants.Bot.sentry_dsn, - integrations=[ - sentry_logging, - AioHttpIntegration(), - RedisIntegration(), - ] -) # Create the redis session instance. redis_session = RedisSession( diff --git a/bot/bot.py b/bot/bot.py index b2e5237fe..892bb3325 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -11,7 +11,7 @@ from async_rediscache import RedisSession from discord.ext import commands from sentry_sdk import push_scope -from bot import DEBUG_MODE, api, constants +from bot import api, constants from bot.async_stats import AsyncStatsClient log = logging.getLogger('bot') @@ -40,7 +40,7 @@ class Bot(commands.Bot): statsd_url = constants.Stats.statsd_host - if DEBUG_MODE: + if constants.DEBUG_MODE: # Since statsd is UDP, there are no errors for sending to a down port. # For this reason, setting the statsd host to 127.0.0.1 for development # will effectively disable stats. diff --git a/bot/constants.py b/bot/constants.py index 6c8b933af..3bc25e767 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -614,7 +614,7 @@ class Event(Enum): # Debug mode -DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False +DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local") # Paths BOT_DIR = os.path.dirname(__file__) diff --git a/bot/log.py b/bot/log.py new file mode 100644 index 000000000..5583c7070 --- /dev/null +++ b/bot/log.py @@ -0,0 +1,86 @@ +import logging +import os +import sys +from logging import Logger, handlers +from pathlib import Path + +import coloredlogs +import sentry_sdk +from sentry_sdk.integrations.aiohttp import AioHttpIntegration +from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.integrations.redis import RedisIntegration + +from bot import constants + +TRACE_LEVEL = 5 + + +def setup() -> None: + """Set up loggers.""" + logging.TRACE = TRACE_LEVEL + logging.addLevelName(TRACE_LEVEL, "TRACE") + Logger.trace = _monkeypatch_trace + + log_level = TRACE_LEVEL if constants.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) + file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") + file_handler.setFormatter(log_format) + + root_log = logging.getLogger() + 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("chardet").setLevel(logging.WARNING) + logging.getLogger(__name__) + + +def setup_sentry() -> None: + """Set up the Sentry logging integrations.""" + sentry_logging = LoggingIntegration( + level=logging.DEBUG, + event_level=logging.WARNING + ) + + sentry_sdk.init( + dsn=constants.Bot.sentry_dsn, + integrations=[ + sentry_logging, + AioHttpIntegration(), + RedisIntegration(), + ] + ) + + +def _monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: + """ + Log 'msg % args' with severity 'TRACE'. + + To pass exception information, use the keyword argument exc_info with + a true value, e.g. + + logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) + """ + if self.isEnabledFor(TRACE_LEVEL): + self._log(TRACE_LEVEL, msg, args, **kwargs) -- cgit v1.2.3 From 9676866990523266d39fc26c4fe6bfa28a8ca9e4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 17 Oct 2020 19:57:29 -0700 Subject: Move bot creation code from __main__.py to bot.py --- bot/__main__.py | 55 ++----------------------------------------------------- bot/bot.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 53 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index f3204c18a..9847c1849 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,58 +1,7 @@ -import asyncio - -import discord -from async_rediscache import RedisSession -from discord.ext.commands import when_mentioned_or - import bot from bot import constants from bot.bot import Bot -from bot.utils.extensions import EXTENSIONS - - -# Create the redis session instance. -redis_session = RedisSession( - address=(constants.Redis.host, constants.Redis.port), - password=constants.Redis.password, - minsize=1, - maxsize=20, - use_fakeredis=constants.Redis.use_fakeredis, - global_namespace="bot", -) - -# Connect redis session to ensure it's connected before we try to access Redis -# from somewhere within the bot. We create the event loop in the same way -# discord.py normally does and pass it to the bot's __init__. -loop = asyncio.get_event_loop() -loop.run_until_complete(redis_session.connect()) - - -# Instantiate the bot. -allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] -intents = discord.Intents().all() -intents.presences = False -intents.dm_typing = False -intents.dm_reactions = False -intents.invites = False -intents.webhooks = False -intents.integrations = False -bot.instance = Bot( - redis_session=redis_session, - loop=loop, - command_prefix=when_mentioned_or(constants.Bot.prefix), - activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"), - case_insensitive=True, - max_messages=10_000, - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), - intents=intents, -) - -# Load extensions. -extensions = set(EXTENSIONS) # Create a mutable copy. -if not constants.HelpChannels.enable: - extensions.remove("bot.exts.help_channels") - -for extension in extensions: - bot.instance.load_extension(extension) +bot.instance = Bot.create() +bot.instance.load_extensions() bot.instance.run(constants.Bot.token) diff --git a/bot/bot.py b/bot/bot.py index 892bb3325..36cf7d30a 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -95,6 +95,43 @@ class Bot(commands.Bot): # Build the FilterList cache self.loop.create_task(self.cache_filter_list_data()) + @classmethod + def create(cls) -> "Bot": + """Create and return an instance of a Bot.""" + loop = asyncio.get_event_loop() + allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] + + intents = discord.Intents().all() + intents.presences = False + intents.dm_typing = False + intents.dm_reactions = False + intents.invites = False + intents.webhooks = False + intents.integrations = False + + return cls( + redis_session=_create_redis_session(loop), + loop=loop, + command_prefix=commands.when_mentioned_or(constants.Bot.prefix), + activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"), + case_insensitive=True, + max_messages=10_000, + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), + intents=intents, + ) + + def load_extensions(self) -> None: + """Load all enabled extensions.""" + # Must be done here to avoid a circular import. + from bot.utils.extensions import EXTENSIONS + + extensions = set(EXTENSIONS) # Create a mutable copy. + if not constants.HelpChannels.enable: + extensions.remove("bot.exts.help_channels") + + for extension in extensions: + self.load_extension(extension) + def add_cog(self, cog: commands.Cog) -> None: """Adds a "cog" to the bot and logs the operation.""" super().add_cog(cog) @@ -243,3 +280,22 @@ class Bot(commands.Bot): for alias in getattr(command, "root_aliases", ()): self.all_commands.pop(alias, None) + + +def _create_redis_session(loop: asyncio.AbstractEventLoop) -> RedisSession: + """ + Create and connect to a redis session. + + Ensure the connection is established before returning to prevent race conditions. + `loop` is the event loop on which to connect. The Bot should use this same event loop. + """ + redis_session = RedisSession( + address=(constants.Redis.host, constants.Redis.port), + password=constants.Redis.password, + minsize=1, + maxsize=20, + use_fakeredis=constants.Redis.use_fakeredis, + global_namespace="bot", + ) + loop.run_until_complete(redis_session.connect()) + return redis_session -- cgit v1.2.3 From e18b5903d13f0bfd98e71627fde32d0a79397981 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 17 Oct 2020 20:33:10 -0700 Subject: Set up Sentry when running rather than upon import It was causing an error if a DSN was not configured. It also feels wrong and confusing to attempt to make a connection just upon import. --- bot/__init__.py | 1 - bot/__main__.py | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/__init__.py b/bot/__init__.py index d2fd107a0..8f880b8e6 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -12,7 +12,6 @@ if TYPE_CHECKING: from bot.bot import Bot log.setup() -log.setup_sentry() # On Windows, the selector event loop is required for aiodns. if os.name == "nt": diff --git a/bot/__main__.py b/bot/__main__.py index 9847c1849..257216fa7 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,6 +1,9 @@ import bot from bot import constants from bot.bot import Bot +from bot.log import setup_sentry + +setup_sentry() bot.instance = Bot.create() bot.instance.load_extensions() -- cgit v1.2.3 From a7f14a1e9055b1dfc794112ba353d401582e6662 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 15 Nov 2020 19:56:24 +0000 Subject: Add Kubernetes deployment manifest --- deployment.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 deployment.yaml diff --git a/deployment.yaml b/deployment.yaml new file mode 100644 index 000000000..ca5ff5941 --- /dev/null +++ b/deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bot +spec: + replicas: 1 + selector: + matchLabels: + app: bot + template: + metadata: + labels: + app: bot + spec: + containers: + - name: bot + image: ghcr.io/python-discord/bot:latest + imagePullPolicy: Always + envFrom: + - secretRef: + name: bot-env -- cgit v1.2.3 From 2cba93b6b0cedf98eaf244cf42e1b3c3faf64615 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 15 Nov 2020 19:56:51 +0000 Subject: Add deploy steps to GitHub Actions --- .github/workflows/lint-test-build.yml | 157 ------------------------------ .github/workflows/lint-test-deploy.yml | 171 +++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 157 deletions(-) delete mode 100644 .github/workflows/lint-test-build.yml create mode 100644 .github/workflows/lint-test-deploy.yml diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml deleted file mode 100644 index c63f78ff6..000000000 --- a/.github/workflows/lint-test-build.yml +++ /dev/null @@ -1,157 +0,0 @@ -name: Lint, Test, Build - -on: - push: - branches: - - master - # We use pull_request_target as we get PRs from - # forks, but need to be able to add annotations - # for our flake8 step. - pull_request_target: - - -jobs: - lint-test: - runs-on: ubuntu-latest - env: - # Dummy values for required bot environment variables - BOT_API_KEY: foo - BOT_SENTRY_DSN: blah - BOT_TOKEN: bar - REDDIT_CLIENT_ID: spam - REDDIT_SECRET: ham - REDIS_PASSWORD: '' - - # Configure pip to cache dependencies and do a user install - PIP_NO_CACHE_DIR: false - PIP_USER: 1 - - # Hide the graphical elements from pipenv's output - PIPENV_HIDE_EMOJIS: 1 - PIPENV_NOSPIN: 1 - - # Make sure pipenv does not try reuse an environment it's running in - PIPENV_IGNORE_VIRTUALENVS: 1 - - # Specify explicit paths for python dependencies and the pre-commit - # environment so we know which directories to cache - PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base - PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache - - steps: - - name: Add custom PYTHONUSERBASE to PATH - run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH - - # We don't want to persist credentials, as our GitHub Action - # may be run when a PR is made from a fork. - - name: Checkout repository - uses: actions/checkout@v2 - with: - persist-credentials: false - - - name: Setup python - id: python - uses: actions/setup-python@v2 - with: - python-version: '3.8' - - # This step caches our Python dependencies. To make sure we - # only restore a cache when the dependencies, the python version, - # the runner operating system, and the dependency location haven't - # changed, we create a cache key that is a composite of those states. - # - # Only when the context is exactly the same, we will restore the cache. - - name: Python Dependency Caching - uses: actions/cache@v2 - id: python_cache - with: - path: ${{ env.PYTHONUSERBASE }} - key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\ - ${{ steps.python.outputs.python-version }}-\ - ${{ hashFiles('./Pipfile', './Pipfile.lock') }}" - - # Install our dependencies if we did not restore a dependency cache - - name: Install dependencies using pipenv - if: steps.python_cache.outputs.cache-hit != 'true' - run: | - pip install pipenv - pipenv install --dev --deploy --system - - # This step caches our pre-commit environment. To make sure we - # do create a new environment when our pre-commit setup changes, - # we create a cache key based on relevant factors. - - name: Pre-commit Environment Caching - uses: actions/cache@v2 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\ - ${{ steps.python.outputs.python-version }}-\ - ${{ hashFiles('./.pre-commit-config.yaml') }}" - - # We will not run `flake8` here, as we will use a separate flake8 - # action. As pre-commit does not support user installs, we set - # PIP_USER=0 to not do a user install. - - name: Run pre-commit hooks - run: export PIP_USER=0; SKIP=flake8 pre-commit run --all-files - - # This step requires `pull_request_target`, as adding annotations - # requires "write" permissions to the repo. - - name: Run flake8 - uses: julianwachholz/flake8-action@v1 - with: - checkName: lint-test - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # We run `coverage` using the `python` command so we can suppress - # irrelevant warnings in our CI output. - - name: Run tests and generate coverage report - run: | - python -Wignore -m coverage run -m unittest - coverage report -m - - # This step will publish the coverage reports coveralls.io and - # print a "job" link in the output of the GitHub Action - - name: Publish coverage report to coveralls.io - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: coveralls - - build-and-push: - needs: lint-test - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master' - runs-on: ubuntu-latest - - steps: - # Create a commit SHA-based tag for the container repositories - - name: Create SHA Container Tag - id: sha_tag - run: | - tag=$(cut -c 1-7 <<< $GITHUB_SHA) - echo "::set-output name=tag::$tag" - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to Github Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GHCR_TOKEN }} - - # This step builds and pushed the container to the - # Github Container Registry tagged with "latest" and - # the short SHA of the commit. - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: . - file: ./Dockerfile - push: true - cache-from: type=registry,ref=ghcr.io/python-discord/bot:latest - tags: | - ghcr.io/python-discord/bot:latest - ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }} diff --git a/.github/workflows/lint-test-deploy.yml b/.github/workflows/lint-test-deploy.yml new file mode 100644 index 000000000..b4003ddc1 --- /dev/null +++ b/.github/workflows/lint-test-deploy.yml @@ -0,0 +1,171 @@ +name: Lint, Test, Build + +on: + push: + branches: + - master + # We use pull_request_target as we get PRs from + # forks, but need to be able to add annotations + # for our flake8 step. + pull_request_target: + + +jobs: + lint-test: + runs-on: ubuntu-latest + env: + # Dummy values for required bot environment variables + BOT_API_KEY: foo + BOT_SENTRY_DSN: blah + BOT_TOKEN: bar + REDDIT_CLIENT_ID: spam + REDDIT_SECRET: ham + REDIS_PASSWORD: '' + + # Configure pip to cache dependencies and do a user install + PIP_NO_CACHE_DIR: false + PIP_USER: 1 + + # Hide the graphical elements from pipenv's output + PIPENV_HIDE_EMOJIS: 1 + PIPENV_NOSPIN: 1 + + # Make sure pipenv does not try reuse an environment it's running in + PIPENV_IGNORE_VIRTUALENVS: 1 + + # Specify explicit paths for python dependencies and the pre-commit + # environment so we know which directories to cache + PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base + PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache + + steps: + - name: Add custom PYTHONUSERBASE to PATH + run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH + + # We don't want to persist credentials, as our GitHub Action + # may be run when a PR is made from a fork. + - name: Checkout repository + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Setup python + id: python + uses: actions/setup-python@v2 + with: + python-version: '3.8' + + # This step caches our Python dependencies. To make sure we + # only restore a cache when the dependencies, the python version, + # the runner operating system, and the dependency location haven't + # changed, we create a cache key that is a composite of those states. + # + # Only when the context is exactly the same, we will restore the cache. + - name: Python Dependency Caching + uses: actions/cache@v2 + id: python_cache + with: + path: ${{ env.PYTHONUSERBASE }} + key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\ + ${{ steps.python.outputs.python-version }}-\ + ${{ hashFiles('./Pipfile', './Pipfile.lock') }}" + + # Install our dependencies if we did not restore a dependency cache + - name: Install dependencies using pipenv + if: steps.python_cache.outputs.cache-hit != 'true' + run: | + pip install pipenv + pipenv install --dev --deploy --system + + # This step caches our pre-commit environment. To make sure we + # do create a new environment when our pre-commit setup changes, + # we create a cache key based on relevant factors. + - name: Pre-commit Environment Caching + uses: actions/cache@v2 + with: + path: ${{ env.PRE_COMMIT_HOME }} + key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\ + ${{ steps.python.outputs.python-version }}-\ + ${{ hashFiles('./.pre-commit-config.yaml') }}" + + # We will not run `flake8` here, as we will use a separate flake8 + # action. As pre-commit does not support user installs, we set + # PIP_USER=0 to not do a user install. + - name: Run pre-commit hooks + run: export PIP_USER=0; SKIP=flake8 pre-commit run --all-files + + # This step requires `pull_request_target`, as adding annotations + # requires "write" permissions to the repo. + - name: Run flake8 + uses: julianwachholz/flake8-action@v1 + with: + checkName: lint-test + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # We run `coverage` using the `python` command so we can suppress + # irrelevant warnings in our CI output. + - name: Run tests and generate coverage report + run: | + python -Wignore -m coverage run -m unittest + coverage report -m + + # This step will publish the coverage reports coveralls.io and + # print a "job" link in the output of the GitHub Action + - name: Publish coverage report to coveralls.io + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: coveralls + + build-and-push: + needs: lint-test + if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + + steps: + # Create a commit SHA-based tag for the container repositories + - name: Create SHA Container Tag + id: sha_tag + run: | + tag=$(cut -c 1-7 <<< $GITHUB_SHA) + echo "::set-output name=tag::$tag" + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to Github Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GHCR_TOKEN }} + + # This step builds and pushed the container to the + # Github Container Registry tagged with "latest" and + # the short SHA of the commit. + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + push: true + cache-from: type=registry,ref=ghcr.io/python-discord/bot:latest + tags: | + ghcr.io/python-discord/bot:latest + ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }} + + - name: Authenticate with Kubernetes + uses: azure/k8s-set-context@v1 + with: + method: kubeconfig + kubeconfig: ${{ secrets.KUBECONFIG }} + + - name: Deploy to Kubernetes + uses: Azure/k8s-deploy@v1 + with: + manifests: | + deployment.yaml + images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}' + kubectl-version: 'latest' -- cgit v1.2.3 From 6c8fed8aeb4850990f9f027401898aeb3330e732 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 15 Nov 2020 19:57:00 +0000 Subject: Update config options with new hosts --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index 8912841ff..ac67251b0 100644 --- a/config-default.yml +++ b/config-default.yml @@ -4,13 +4,13 @@ bot: sentry_dsn: !ENV "BOT_SENTRY_DSN" redis: - host: "redis" + host: "redis.default.svc.cluster.local" port: 6379 password: !ENV "REDIS_PASSWORD" use_fakeredis: false stats: - statsd_host: "graphite" + statsd_host: "graphite.default.svc.cluster.local" presence_update_timeout: 300 cooldowns: -- cgit v1.2.3 From ded520e374535b86013ca3a1de5c5eb1d3444bcb Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Wed, 18 Nov 2020 18:55:36 +0100 Subject: Pull snekbox image from GHCR in docker-compose We're in the process of migrating snekbox to the GitHub Container Repository, which will replace DockerHub. I've changed docker-compose to reflect that change. Signed-off-by: Sebastiaan Zeeff --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index dc89e8885..0002d1d56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: - "127.0.0.1:6379:6379" snekbox: - image: pythondiscord/snekbox:latest + image: ghcr.io/python-discord/snekbox:latest init: true ipc: none ports: -- cgit v1.2.3 From ccd0e150d34693ff0d459e7b2d0300b30192e987 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Wed, 18 Nov 2020 21:15:29 +0100 Subject: Make sure we lint the actual pull request Unfortunately, our old setup did not actually lint the PR, as it was running in the context of the target repository. To sidestep the issue of using `pull_request_target` altogether, I've now changed our run of flake8 to using it directly and having it output its errors in teh format of Workflow Commands. This means that our flake8 output will not be translated automatically in annotations for the run. In addition, I've split up the workflow into two separate files: one for linting & testing and one for building (& deploying). Signed-off-by: Sebastiaan Zeeff --- .github/workflows/build.yml | 51 +++++++++++ .github/workflows/lint-test-build.yml | 157 ---------------------------------- .github/workflows/lint-test.yml | 115 +++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 157 deletions(-) create mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/lint-test-build.yml create mode 100644 .github/workflows/lint-test.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..fa1449c85 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,51 @@ +name: Build + +on: + workflow_run: + workflows: ["Lint & Test"] + branches: + - master + types: + - completed + +jobs: + build: + if: github.event.workflow_run.conclusion == 'success' + name: Build & Push + runs-on: ubuntu-latest + + steps: + # Create a commit SHA-based tag for the container repositories + - name: Create SHA Container Tag + id: sha_tag + run: | + tag=$(cut -c 1-7 <<< $GITHUB_SHA) + echo "::set-output name=tag::$tag" + + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to Github Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GHCR_TOKEN }} + + # Build and push the container to the GitHub Container + # Repository. The container will be tagged as "latest" + # and with the short SHA of the commit. + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + push: true + cache-from: type=registry,ref=ghcr.io/python-discord/bot:latest + cache-to: type=inline + tags: | + ghcr.io/python-discord/bot:latest + ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }} diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml deleted file mode 100644 index c63f78ff6..000000000 --- a/.github/workflows/lint-test-build.yml +++ /dev/null @@ -1,157 +0,0 @@ -name: Lint, Test, Build - -on: - push: - branches: - - master - # We use pull_request_target as we get PRs from - # forks, but need to be able to add annotations - # for our flake8 step. - pull_request_target: - - -jobs: - lint-test: - runs-on: ubuntu-latest - env: - # Dummy values for required bot environment variables - BOT_API_KEY: foo - BOT_SENTRY_DSN: blah - BOT_TOKEN: bar - REDDIT_CLIENT_ID: spam - REDDIT_SECRET: ham - REDIS_PASSWORD: '' - - # Configure pip to cache dependencies and do a user install - PIP_NO_CACHE_DIR: false - PIP_USER: 1 - - # Hide the graphical elements from pipenv's output - PIPENV_HIDE_EMOJIS: 1 - PIPENV_NOSPIN: 1 - - # Make sure pipenv does not try reuse an environment it's running in - PIPENV_IGNORE_VIRTUALENVS: 1 - - # Specify explicit paths for python dependencies and the pre-commit - # environment so we know which directories to cache - PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base - PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache - - steps: - - name: Add custom PYTHONUSERBASE to PATH - run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH - - # We don't want to persist credentials, as our GitHub Action - # may be run when a PR is made from a fork. - - name: Checkout repository - uses: actions/checkout@v2 - with: - persist-credentials: false - - - name: Setup python - id: python - uses: actions/setup-python@v2 - with: - python-version: '3.8' - - # This step caches our Python dependencies. To make sure we - # only restore a cache when the dependencies, the python version, - # the runner operating system, and the dependency location haven't - # changed, we create a cache key that is a composite of those states. - # - # Only when the context is exactly the same, we will restore the cache. - - name: Python Dependency Caching - uses: actions/cache@v2 - id: python_cache - with: - path: ${{ env.PYTHONUSERBASE }} - key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\ - ${{ steps.python.outputs.python-version }}-\ - ${{ hashFiles('./Pipfile', './Pipfile.lock') }}" - - # Install our dependencies if we did not restore a dependency cache - - name: Install dependencies using pipenv - if: steps.python_cache.outputs.cache-hit != 'true' - run: | - pip install pipenv - pipenv install --dev --deploy --system - - # This step caches our pre-commit environment. To make sure we - # do create a new environment when our pre-commit setup changes, - # we create a cache key based on relevant factors. - - name: Pre-commit Environment Caching - uses: actions/cache@v2 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\ - ${{ steps.python.outputs.python-version }}-\ - ${{ hashFiles('./.pre-commit-config.yaml') }}" - - # We will not run `flake8` here, as we will use a separate flake8 - # action. As pre-commit does not support user installs, we set - # PIP_USER=0 to not do a user install. - - name: Run pre-commit hooks - run: export PIP_USER=0; SKIP=flake8 pre-commit run --all-files - - # This step requires `pull_request_target`, as adding annotations - # requires "write" permissions to the repo. - - name: Run flake8 - uses: julianwachholz/flake8-action@v1 - with: - checkName: lint-test - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # We run `coverage` using the `python` command so we can suppress - # irrelevant warnings in our CI output. - - name: Run tests and generate coverage report - run: | - python -Wignore -m coverage run -m unittest - coverage report -m - - # This step will publish the coverage reports coveralls.io and - # print a "job" link in the output of the GitHub Action - - name: Publish coverage report to coveralls.io - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: coveralls - - build-and-push: - needs: lint-test - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master' - runs-on: ubuntu-latest - - steps: - # Create a commit SHA-based tag for the container repositories - - name: Create SHA Container Tag - id: sha_tag - run: | - tag=$(cut -c 1-7 <<< $GITHUB_SHA) - echo "::set-output name=tag::$tag" - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to Github Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GHCR_TOKEN }} - - # This step builds and pushed the container to the - # Github Container Registry tagged with "latest" and - # the short SHA of the commit. - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: . - file: ./Dockerfile - push: true - cache-from: type=registry,ref=ghcr.io/python-discord/bot:latest - tags: | - ghcr.io/python-discord/bot:latest - ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }} diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml new file mode 100644 index 000000000..5444fc3de --- /dev/null +++ b/.github/workflows/lint-test.yml @@ -0,0 +1,115 @@ +name: Lint & Test + +on: + push: + branches: + - master + pull_request: + + +jobs: + lint-test: + runs-on: ubuntu-latest + env: + # Dummy values for required bot environment variables + BOT_API_KEY: foo + BOT_SENTRY_DSN: blah + BOT_TOKEN: bar + REDDIT_CLIENT_ID: spam + REDDIT_SECRET: ham + REDIS_PASSWORD: '' + + # Configure pip to cache dependencies and do a user install + PIP_NO_CACHE_DIR: false + PIP_USER: 1 + + # Hide the graphical elements from pipenv's output + PIPENV_HIDE_EMOJIS: 1 + PIPENV_NOSPIN: 1 + + # Make sure pipenv does not try reuse an environment it's running in + PIPENV_IGNORE_VIRTUALENVS: 1 + + # Specify explicit paths for python dependencies and the pre-commit + # environment so we know which directories to cache + PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base + PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache + + steps: + - name: Add custom PYTHONUSERBASE to PATH + run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH + + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Setup python + id: python + uses: actions/setup-python@v2 + with: + python-version: '3.8' + + # This step caches our Python dependencies. To make sure we + # only restore a cache when the dependencies, the python version, + # the runner operating system, and the dependency location haven't + # changed, we create a cache key that is a composite of those states. + # + # Only when the context is exactly the same, we will restore the cache. + - name: Python Dependency Caching + uses: actions/cache@v2 + id: python_cache + with: + path: ${{ env.PYTHONUSERBASE }} + key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\ + ${{ steps.python.outputs.python-version }}-\ + ${{ hashFiles('./Pipfile', './Pipfile.lock') }}" + + # Install our dependencies if we did not restore a dependency cache + - name: Install dependencies using pipenv + if: steps.python_cache.outputs.cache-hit != 'true' + run: | + pip install pipenv + pipenv install --dev --deploy --system + + # This step caches our pre-commit environment. To make sure we + # do create a new environment when our pre-commit setup changes, + # we create a cache key based on relevant factors. + - name: Pre-commit Environment Caching + uses: actions/cache@v2 + with: + path: ${{ env.PRE_COMMIT_HOME }} + key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\ + ${{ steps.python.outputs.python-version }}-\ + ${{ hashFiles('./.pre-commit-config.yaml') }}" + + # We will not run `flake8` here, as we will use a separate flake8 + # action. As pre-commit does not support user installs, we set + # PIP_USER=0 to not do a user install. + - name: Run pre-commit hooks + run: export PIP_USER=0; SKIP=flake8 pre-commit run --all-files + + # Run flake8 and have it format the linting errors in the format of + # the GitHub Workflow command to register error annotations. This + # means that our flake8 output is automatically added as an error + # annotation to both the run result and in the "Files" tab of a + # pull request. + # + # Format used: + # ::error file={filename},line={line},col={col}::{message} + - name: Run flake8 + run: "flake8 \ + --format='::error file=%(path)s,line=%(row)d,col=%(col)d::\ + [flake8] %(code)s: %(text)s'" + + # We run `coverage` using the `python` command so we can suppress + # irrelevant warnings in our CI output. + - name: Run tests and generate coverage report + run: | + python -Wignore -m coverage run -m unittest + coverage report -m + + # This step will publish the coverage reports coveralls.io and + # print a "job" link in the output of the GitHub Action + - name: Publish coverage report to coveralls.io + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: coveralls -- cgit v1.2.3 From 5fe041d1e67ee767788d02f0428250213c43acce Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Wed, 18 Nov 2020 21:35:27 +0100 Subject: Update badges in README to new workflows Signed-off-by: Sebastiaan Zeeff --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 482ada08c..210b3e047 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Python Utility Bot [![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn) -![Lint, Test, Build](https://github.com/python-discord/bot/workflows/Lint,%20Test,%20Build/badge.svg?branch=master) +[![Lint & Test][1]][2] +[![Build][3]][4] [![Coverage Status](https://coveralls.io/repos/github/python-discord/bot/badge.svg)](https://coveralls.io/github/python-discord/bot) [![License](https://img.shields.io/github/license/python-discord/bot)](LICENSE) [![Website](https://img.shields.io/badge/website-visit-brightgreen)](https://pythondiscord.com) @@ -10,3 +11,8 @@ This project is a Discord bot specifically for use with the Python Discord serve and other tools to help keep the server running like a well-oiled machine. Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/) on our website if you're interested in helping out. + +[1]: https://github.com/python-discord/bot/workflows/Lint%20&%20Test/badge.svg?branch=master +[2]: https://github.com/python-discord/bot/actions?query=workflow%3A%22Lint+%26+Test%22+branch%3Amaster +[3]: https://github.com/python-discord/bot/workflows/Build/badge.svg?branch=master +[4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster -- cgit v1.2.3 From 6b07eb115a5db91579a35f8ce899c6ea5943ef1d Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Wed, 18 Nov 2020 21:39:46 +0100 Subject: Use GHCR image tags in Pipfile Signed-off-by: Sebastiaan Zeeff --- Pipfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Pipfile b/Pipfile index 0730b9150..103ce84cf 100644 --- a/Pipfile +++ b/Pipfile @@ -48,8 +48,8 @@ python_version = "3.8" start = "python -m bot" lint = "pre-commit run --all-files" precommit = "pre-commit install" -build = "docker build -t pythondiscord/bot:latest -f Dockerfile ." -push = "docker push pythondiscord/bot:latest" +build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ." +push = "docker push ghcr.io/python-discord/bot:latest" test = "coverage run -m unittest" html = "coverage html" report = "coverage report" -- cgit v1.2.3 From 79404ca86434382c297a8247fed06d820323cdc5 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Thu, 19 Nov 2020 00:17:15 +0100 Subject: Add comment explaining buildx to workflow It's better to document these steps. Signed-off-by: Sebastiaan Zeeff --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fa1449c85..706ab462f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,12 @@ jobs: - name: Checkout code uses: actions/checkout@v2 + # The current version (v2) of Docker's build-push action uses + # buildx, which comes with BuildKit features that help us speed + # up our builds using additional cache features. Buildx also + # has a lot of other features that are not as relevant to us. + # + # See https://github.com/docker/build-push-action - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 -- cgit v1.2.3 From ebd440ac8aff27ad70f6a59fde6af15fa8c61b68 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 19 Nov 2020 00:27:05 +0000 Subject: Update snekbox address in config-default.yml --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index ac67251b0..89493c4de 100644 --- a/config-default.yml +++ b/config-default.yml @@ -329,7 +329,7 @@ urls: paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] # Snekbox - snekbox_eval_api: "http://snekbox:8060/eval" + snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval" # Discord API URLs discord_api: &DISCORD_API "https://discordapp.com/api/v7/" -- cgit v1.2.3 From d65785aa4c189180601521dc0402d63d95e5bebe Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Thu, 19 Nov 2020 16:09:26 +0100 Subject: Fix the deploy stage of our build pipeline I've fixed the deploy stage of our build pipeline, as it got mixed in with the old workflow file due to a merge conflict. The deploy stage is currently split into a separate workflow; theoretically, this allows us to trigger a redeploy from GitHub, without having to build the container image again. Signed-off-by: Sebastiaan Zeeff --- .github/workflows/deploy.yml | 30 ++++++ .github/workflows/lint-test-deploy.yml | 171 --------------------------------- README.md | 7 +- 3 files changed, 36 insertions(+), 172 deletions(-) create mode 100644 .github/workflows/deploy.yml delete mode 100644 .github/workflows/lint-test-deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..0e9d3e079 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,30 @@ +name: Deploy + +on: + workflow_run: + workflows: ["Build"] + branches: + - master + types: + - completed + +jobs: + build: + if: github.event.workflow_run.conclusion == 'success' + name: Build & Push + runs-on: ubuntu-latest + + steps: + - name: Authenticate with Kubernetes + uses: azure/k8s-set-context@v1 + with: + method: kubeconfig + kubeconfig: ${{ secrets.KUBECONFIG }} + + - name: Deploy to Kubernetes + uses: Azure/k8s-deploy@v1 + with: + manifests: | + deployment.yaml + images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}' + kubectl-version: 'latest' diff --git a/.github/workflows/lint-test-deploy.yml b/.github/workflows/lint-test-deploy.yml deleted file mode 100644 index b4003ddc1..000000000 --- a/.github/workflows/lint-test-deploy.yml +++ /dev/null @@ -1,171 +0,0 @@ -name: Lint, Test, Build - -on: - push: - branches: - - master - # We use pull_request_target as we get PRs from - # forks, but need to be able to add annotations - # for our flake8 step. - pull_request_target: - - -jobs: - lint-test: - runs-on: ubuntu-latest - env: - # Dummy values for required bot environment variables - BOT_API_KEY: foo - BOT_SENTRY_DSN: blah - BOT_TOKEN: bar - REDDIT_CLIENT_ID: spam - REDDIT_SECRET: ham - REDIS_PASSWORD: '' - - # Configure pip to cache dependencies and do a user install - PIP_NO_CACHE_DIR: false - PIP_USER: 1 - - # Hide the graphical elements from pipenv's output - PIPENV_HIDE_EMOJIS: 1 - PIPENV_NOSPIN: 1 - - # Make sure pipenv does not try reuse an environment it's running in - PIPENV_IGNORE_VIRTUALENVS: 1 - - # Specify explicit paths for python dependencies and the pre-commit - # environment so we know which directories to cache - PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base - PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache - - steps: - - name: Add custom PYTHONUSERBASE to PATH - run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH - - # We don't want to persist credentials, as our GitHub Action - # may be run when a PR is made from a fork. - - name: Checkout repository - uses: actions/checkout@v2 - with: - persist-credentials: false - - - name: Setup python - id: python - uses: actions/setup-python@v2 - with: - python-version: '3.8' - - # This step caches our Python dependencies. To make sure we - # only restore a cache when the dependencies, the python version, - # the runner operating system, and the dependency location haven't - # changed, we create a cache key that is a composite of those states. - # - # Only when the context is exactly the same, we will restore the cache. - - name: Python Dependency Caching - uses: actions/cache@v2 - id: python_cache - with: - path: ${{ env.PYTHONUSERBASE }} - key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\ - ${{ steps.python.outputs.python-version }}-\ - ${{ hashFiles('./Pipfile', './Pipfile.lock') }}" - - # Install our dependencies if we did not restore a dependency cache - - name: Install dependencies using pipenv - if: steps.python_cache.outputs.cache-hit != 'true' - run: | - pip install pipenv - pipenv install --dev --deploy --system - - # This step caches our pre-commit environment. To make sure we - # do create a new environment when our pre-commit setup changes, - # we create a cache key based on relevant factors. - - name: Pre-commit Environment Caching - uses: actions/cache@v2 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\ - ${{ steps.python.outputs.python-version }}-\ - ${{ hashFiles('./.pre-commit-config.yaml') }}" - - # We will not run `flake8` here, as we will use a separate flake8 - # action. As pre-commit does not support user installs, we set - # PIP_USER=0 to not do a user install. - - name: Run pre-commit hooks - run: export PIP_USER=0; SKIP=flake8 pre-commit run --all-files - - # This step requires `pull_request_target`, as adding annotations - # requires "write" permissions to the repo. - - name: Run flake8 - uses: julianwachholz/flake8-action@v1 - with: - checkName: lint-test - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # We run `coverage` using the `python` command so we can suppress - # irrelevant warnings in our CI output. - - name: Run tests and generate coverage report - run: | - python -Wignore -m coverage run -m unittest - coverage report -m - - # This step will publish the coverage reports coveralls.io and - # print a "job" link in the output of the GitHub Action - - name: Publish coverage report to coveralls.io - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: coveralls - - build-and-push: - needs: lint-test - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master' - runs-on: ubuntu-latest - - steps: - # Create a commit SHA-based tag for the container repositories - - name: Create SHA Container Tag - id: sha_tag - run: | - tag=$(cut -c 1-7 <<< $GITHUB_SHA) - echo "::set-output name=tag::$tag" - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to Github Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GHCR_TOKEN }} - - # This step builds and pushed the container to the - # Github Container Registry tagged with "latest" and - # the short SHA of the commit. - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: . - file: ./Dockerfile - push: true - cache-from: type=registry,ref=ghcr.io/python-discord/bot:latest - tags: | - ghcr.io/python-discord/bot:latest - ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }} - - - name: Authenticate with Kubernetes - uses: azure/k8s-set-context@v1 - with: - method: kubeconfig - kubeconfig: ${{ secrets.KUBECONFIG }} - - - name: Deploy to Kubernetes - uses: Azure/k8s-deploy@v1 - with: - manifests: | - deployment.yaml - images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}' - kubectl-version: 'latest' diff --git a/README.md b/README.md index 210b3e047..c813997e7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Python Utility Bot -[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn) +[![Discord][7]][8] [![Lint & Test][1]][2] [![Build][3]][4] +[![Deploy][5]][6] [![Coverage Status](https://coveralls.io/repos/github/python-discord/bot/badge.svg)](https://coveralls.io/github/python-discord/bot) [![License](https://img.shields.io/github/license/python-discord/bot)](LICENSE) [![Website](https://img.shields.io/badge/website-visit-brightgreen)](https://pythondiscord.com) @@ -16,3 +17,7 @@ Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/) [2]: https://github.com/python-discord/bot/actions?query=workflow%3A%22Lint+%26+Test%22+branch%3Amaster [3]: https://github.com/python-discord/bot/workflows/Build/badge.svg?branch=master [4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster +[5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=master +[6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amaster +[7]: https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white +[8]: https://discord.gg/2B963hn -- cgit v1.2.3 From b287165e01c78e36af2cfbc19366555359ffdc18 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 19 Nov 2020 17:31:12 +0100 Subject: Checkout code so we can deploy --- .github/workflows/deploy.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0e9d3e079..90555a8ee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,6 +15,15 @@ jobs: runs-on: ubuntu-latest steps: + - name: Create SHA Container Tag + id: sha_tag + run: | + tag=$(cut -c 1-7 <<< $GITHUB_SHA) + echo "::set-output name=tag::$tag" + + - name: Checkout code + uses: actions/checkout@v2 + - name: Authenticate with Kubernetes uses: azure/k8s-set-context@v1 with: -- cgit v1.2.3