From c575afb745dc7c86cd7aef21ca6920a8fbbe7e9b Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 14 Apr 2024 16:32:42 -0400 Subject: [PATCH 1/4] Fix Issue #166 Moved invite URL generation to global function within utils/generate_invite_url.py. Deleted utils/__main__.py and utils/base_utility_function.py. Removed main() from utils/init.py. Moved call to generate_invite_url() to startup.py. - If main guild not set, invite URL will be outputted and bot will be shutdown. - If main guild is set, invite URL will only be outputted if CONSOLE_LOG_LEVEL in environment variable set to DEBUG. I had to modify tests for generate_invite_url() in test/tests_util.py reflect new behavior as it no longer takes in command line arguments. - You may need to modify it accordingly to remove irrelevant test cases and adjust to new behavior. --- cogs/startup.py | 11 ++- utils/__init__.py | 42 +-------- utils/__main__.py | 10 --- utils/base_utility_function.py | 59 ------------ utils/generate_invite_url.py | 158 ++++++++------------------------- 5 files changed, 49 insertions(+), 231 deletions(-) delete mode 100644 utils/__main__.py delete mode 100644 utils/base_utility_function.py diff --git a/cogs/startup.py b/cogs/startup.py index 9ade68344..da23c7924 100644 --- a/cogs/startup.py +++ b/cogs/startup.py @@ -20,7 +20,7 @@ MemberRoleDoesNotExistError, RolesChannelDoesNotExistError, ) -from utils import TeXBotBaseCog +from utils import TeXBotBaseCog, generate_invite_url logger: Logger = logging.getLogger("TeX-Bot") @@ -67,10 +67,19 @@ async def on_ready(self) -> None: self.bot.set_main_guild(main_guild) if not main_guild: + logger.info( + "Invite URL: %s", + generate_invite_url(str(self.bot.application_id), str(settings["DISCORD_GUILD_ID"])) + ) logger.critical(GuildDoesNotExistError(guild_id=settings["DISCORD_GUILD_ID"])) await self.bot.close() return + logger.debug( + "Invite URL: %s", + generate_invite_url(str(self.bot.application_id), str(settings["DISCORD_GUILD_ID"])) + ) + if not discord.utils.get(main_guild.roles, name="Committee"): logger.warning(CommitteeRoleDoesNotExistError()) diff --git a/utils/__init__.py b/utils/__init__.py index 157e349a2..1ec896d7f 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -4,61 +4,23 @@ __all__: Sequence[str] = ( "CommandChecks", - "InviteURLGenerator", - "main", "MessageSenderComponent", "SuppressTraceback", "TeXBot", "TeXBotBaseCog", "TeXBotApplicationContext", "TeXBotAutocompleteContext", - "UtilityFunction", + "generate_invite_url" ) -from argparse import ArgumentParser, Namespace -from collections.abc import Iterable -from typing import TYPE_CHECKING, Final -from utils.base_utility_function import UtilityFunction from utils.command_checks import CommandChecks -from utils.generate_invite_url import InviteURLGenerator +from utils.generate_invite_url import generate_invite_url from utils.message_sender_components import MessageSenderComponent from utils.suppress_traceback import SuppressTraceback from utils.tex_bot import TeXBot from utils.tex_bot_base_cog import TeXBotBaseCog from utils.tex_bot_contexts import TeXBotApplicationContext, TeXBotAutocompleteContext -if TYPE_CHECKING: - # noinspection PyProtectedMember - from argparse import _SubParsersAction as SubParsersAction -def main(argv: Sequence[str] | None = None, utility_functions: Iterable[type[UtilityFunction]] | None = None) -> int: # noqa: E501 - """Run this script as a CLI tool with argument parsing.""" - utility_functions = set() if utility_functions is None else set(utility_functions) - - arg_parser: ArgumentParser = ArgumentParser( - prog="utils", - description="Executes common command-line utility functions" - ) - function_subparsers: SubParsersAction[ArgumentParser] = arg_parser.add_subparsers( - title="functions", - required=True, - help="Utility function to execute", - dest="function" - ) - - utility_function: type[UtilityFunction] - for utility_function in utility_functions: - utility_function.attach_to_parser(function_subparsers) - - parsed_args: Namespace = arg_parser.parse_args(argv) - - for utility_function in utility_functions: - if parsed_args.function == utility_function.NAME: - return utility_function.run(parsed_args, function_subparsers) - - NO_FUNCTION_EXECUTED_MESSAGE: Final[str] = ( - "Valid function name provided, but not executed." - ) - raise RuntimeError(NO_FUNCTION_EXECUTED_MESSAGE) diff --git a/utils/__main__.py b/utils/__main__.py deleted file mode 100644 index 3f8d963c2..000000000 --- a/utils/__main__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Command-line execution of the utils package.""" - -from collections.abc import Sequence - -__all__: Sequence[str] = () - -from utils import InviteURLGenerator, main - -if __name__ == "__main__": - raise SystemExit(main(utility_functions={InviteURLGenerator})) diff --git a/utils/base_utility_function.py b/utils/base_utility_function.py deleted file mode 100644 index 98237c696..000000000 --- a/utils/base_utility_function.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Base component definition of a generic Utility Function.""" - -from collections.abc import Sequence - -__all__: Sequence[str] = ("UtilityFunction",) - -import abc -import logging -from argparse import ArgumentParser, Namespace -from logging import Logger -from typing import TYPE_CHECKING, ClassVar, Final, Self - -if TYPE_CHECKING: - # noinspection PyProtectedMember - from argparse import _SubParsersAction as SubParsersAction - -logger: Logger = logging.getLogger("texbot") - - -class UtilityFunction(abc.ABC): - """ - Abstract component of a utility function. - - Subclasses declare the actual execution logic of each utility function. - """ - - NAME: str - DESCRIPTION: str - _function_subparsers: ClassVar[dict["SubParsersAction[ArgumentParser]", ArgumentParser]] = {} # noqa: E501 - - # noinspection PyTypeChecker,PyTypeHints - def __new__(cls, *_args: object, **_kwargs: object) -> Self: - """Instance objects of UtilityFunctions cannot be instantiated.""" - CANNOT_INSTANTIATE_INSTANCE_MESSAGE: Final[str] = ( - f"Cannot instantiate {cls.__name__} object instance." - ) - raise RuntimeError(CANNOT_INSTANTIATE_INSTANCE_MESSAGE) - - @classmethod - def attach_to_parser(cls, parser: "SubParsersAction[ArgumentParser]") -> None: - """ - Add a subparser to the provided argument parser. - - This allows the subparser to retrieve arguments specific to this utility function. - """ - if parser in cls._function_subparsers: - logger.warning( - "This UtilityFunction has already been attached to the given parser." - ) - else: - cls._function_subparsers[parser] = parser.add_parser( - cls.NAME, - description=cls.DESCRIPTION - ) - - @classmethod - @abc.abstractmethod - def run(cls, parsed_args: Namespace, parser: "SubParsersAction[ArgumentParser]") -> int: - """Execute the logic that this util function provides.""" diff --git a/utils/generate_invite_url.py b/utils/generate_invite_url.py index 9dbe5e7d9..c59025564 100644 --- a/utils/generate_invite_url.py +++ b/utils/generate_invite_url.py @@ -1,128 +1,44 @@ """Utility function to generate the URL to invite the bot to a given Discord guild.""" -from collections.abc import Sequence - -__all__: Sequence[str] = ("InviteURLGenerator",) - -import os import re -import sys -from argparse import Namespace -from typing import TYPE_CHECKING, Final +from collections.abc import Sequence import discord -from utils.base_utility_function import UtilityFunction - -if TYPE_CHECKING: - from argparse import ArgumentParser - - # noinspection PyProtectedMember - from argparse import _SubParsersAction as SubParsersAction - - -class InviteURLGenerator(UtilityFunction): - """Utility function to generate the URL to invite the bot to a given Discord guild.""" - - NAME: str = "generate_invite_url" - DESCRIPTION: str = "Generate the URL to invite the bot to a given Discord guild" - - @classmethod - def attach_to_parser(cls, parser: "SubParsersAction[ArgumentParser]") -> None: - """ - Add a subparser to the provided argument parser. - - This allows the subparser to retrieve arguments specific to this utility function. - """ - super().attach_to_parser(parser) - - if parser not in cls._function_subparsers: - FUNCTION_SUBPARSER_DOES_NOT_EXIST_MESSAGE: Final[str] = ( - f"""{"self.function_subparser"!r} does not exist.""" - ) - raise RuntimeError(FUNCTION_SUBPARSER_DOES_NOT_EXIST_MESSAGE) - - cls._function_subparsers[parser].add_argument( - "discord_bot_application_id", - help="Must be a valid Discord application ID (see https://support-dev.discord.com/hc/en-gb/articles/360028717192-Where-can-I-find-my-Application-Team-Server-ID-)" - ) - cls._function_subparsers[parser].add_argument( - "discord_guild_id", - nargs="?", - help=( - "The value of the environment variable DISCORD_GUILD_ID is used " - "if this argument is omitted. Must be a valid Discord guild ID " - "(see https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id)" - ) - ) - - @classmethod - def run(cls, parsed_args: Namespace, parser: "SubParsersAction[ArgumentParser]") -> int: - """Execute the logic that this util function provides.""" - if parser not in cls._function_subparsers: - FUNCTION_SUBPARSER_DOES_NOT_EXIST_MESSAGE: Final[str] = ( - f"""{"self.function_subparser"!r} does not exist.""" - ) - raise RuntimeError(FUNCTION_SUBPARSER_DOES_NOT_EXIST_MESSAGE) - - if not re.match(r"\A\d{17,20}\Z", parsed_args.discord_bot_application_id): - cls._function_subparsers[parser].error( - "discord_bot_application_id must be a valid Discord application ID (see https://support-dev.discord.com/hc/en-gb/articles/360028717192-Where-can-I-find-my-Application-Team-Server-ID-)" - ) - - discord_guild_id: str | None = parsed_args.discord_guild_id or None - if not discord_guild_id: - import dotenv - - dotenv.load_dotenv() - discord_guild_id = os.getenv("DISCORD_GUILD_ID") - - if not discord_guild_id: - cls._function_subparsers[parser].error( - "discord_guild_id must be provided as an argument " - "to the generate_invite_url utility function " - "or otherwise set the DISCORD_GUILD_ID environment variable" - ) - - if not discord_guild_id or not re.match(r"\A\d{17,20}\Z", discord_guild_id): - cls._function_subparsers[parser].error( - "discord_guild_id must be a valid Discord guild ID (see https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id)" - ) - - sys.stdout.write(f"""{cls.generate_invite_url( - parsed_args.discord_bot_application_id, - int(discord_guild_id) - )}\n""") - return 0 - - # noinspection PyShadowingNames - @staticmethod - def generate_invite_url(discord_bot_application_id: str, discord_guild_id: int) -> str: - """ - Generate the correct OAuth invite URL for the bot. - - This invite URL directs to the given Discord guild and requests only the permissions - required for the bot to run. - """ - return discord.utils.oauth_url( - client_id=discord_bot_application_id, - permissions=discord.Permissions( - manage_roles=True, - read_messages=True, - send_messages=True, - manage_messages=True, - embed_links=True, - read_message_history=True, - mention_everyone=True, - add_reactions=True, - use_slash_commands=True, - kick_members=True, - ban_members=True, - manage_channels=True, - view_audit_log=True - ), - guild=discord.Object(id=discord_guild_id), - scopes=("bot", "applications.commands"), - disable_guild_select=True - ) +__all__: Sequence[str] = ("generate_invite_url",) + +def generate_invite_url(discord_bot_application_id: str, discord_guild_id: str) -> str: + """Execute the logic that this util function provides.""" + discord_bot_application_id = str(discord_bot_application_id) + discord_guild_id = str(discord_guild_id) + + if not discord_guild_id: + err = "discord_guild_id must be set in the DISCORD_GUILD_ID environment variable" + raise ValueError(err) + + if not discord_guild_id or not re.match(r"\A\d{17,20}\Z", discord_guild_id): + err = "discord_guild_id must be a valid Discord guild ID (see https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id)" + raise ValueError(err) + + return discord.utils.oauth_url( + client_id=discord_bot_application_id, + permissions=discord.Permissions( + manage_roles=True, + read_messages=True, + send_messages=True, + manage_messages=True, + embed_links=True, + read_message_history=True, + mention_everyone=True, + add_reactions=True, + use_slash_commands=True, + kick_members=True, + ban_members=True, + manage_channels=True, + view_audit_log=True + ), + guild=discord.Object(id=discord_guild_id), + scopes=("bot", "applications.commands"), + disable_guild_select=True + ) From b34844c31aa71e5f1f3e13d3aadfd75226f23751 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 14 Apr 2024 17:38:25 -0400 Subject: [PATCH 2/4] Fixed Pull Request #175 Ruff Issues --- cogs/startup.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cogs/startup.py b/cogs/startup.py index da23c7924..dae08aeee 100644 --- a/cogs/startup.py +++ b/cogs/startup.py @@ -69,7 +69,9 @@ async def on_ready(self) -> None: if not main_guild: logger.info( "Invite URL: %s", - generate_invite_url(str(self.bot.application_id), str(settings["DISCORD_GUILD_ID"])) + generate_invite_url( + str(self.bot.application_id), + str(settings["DISCORD_GUILD_ID"])) ) logger.critical(GuildDoesNotExistError(guild_id=settings["DISCORD_GUILD_ID"])) await self.bot.close() @@ -77,7 +79,9 @@ async def on_ready(self) -> None: logger.debug( "Invite URL: %s", - generate_invite_url(str(self.bot.application_id), str(settings["DISCORD_GUILD_ID"])) + generate_invite_url( + str(self.bot.application_id), + str(settings["DISCORD_GUILD_ID"])) ) if not discord.utils.get(main_guild.roles, name="Committee"): From cffc9a8bee12b245d07f9808060e88c052f4902c Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 14 Apr 2024 17:41:57 -0400 Subject: [PATCH 3/4] Trailing Whitespace in cogs/startup.py removed --- cogs/startup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/startup.py b/cogs/startup.py index dae08aeee..437020e84 100644 --- a/cogs/startup.py +++ b/cogs/startup.py @@ -70,7 +70,7 @@ async def on_ready(self) -> None: logger.info( "Invite URL: %s", generate_invite_url( - str(self.bot.application_id), + str(self.bot.application_id), str(settings["DISCORD_GUILD_ID"])) ) logger.critical(GuildDoesNotExistError(guild_id=settings["DISCORD_GUILD_ID"])) @@ -80,7 +80,7 @@ async def on_ready(self) -> None: logger.debug( "Invite URL: %s", generate_invite_url( - str(self.bot.application_id), + str(self.bot.application_id), str(settings["DISCORD_GUILD_ID"])) ) From 15dab8b670f07d59e4db54698544f586048bcca6 Mon Sep 17 00:00:00 2001 From: Edwin Date: Fri, 19 Apr 2024 19:13:21 -0400 Subject: [PATCH 4/4] Update test_utility functions and Remove unnecessary checks --- cogs/startup.py | 26 +- tests/test_utils.py | 460 ++++------------------------------- utils/generate_invite_url.py | 18 +- 3 files changed, 60 insertions(+), 444 deletions(-) diff --git a/cogs/startup.py b/cogs/startup.py index 437020e84..ad769319e 100644 --- a/cogs/startup.py +++ b/cogs/startup.py @@ -67,22 +67,24 @@ async def on_ready(self) -> None: self.bot.set_main_guild(main_guild) if not main_guild: - logger.info( - "Invite URL: %s", - generate_invite_url( - str(self.bot.application_id), - str(settings["DISCORD_GUILD_ID"])) - ) + if self.bot.application_id: + logger.info( + "Invite URL: %s", + generate_invite_url( + self.bot.application_id, + settings["DISCORD_GUILD_ID"]) + ) logger.critical(GuildDoesNotExistError(guild_id=settings["DISCORD_GUILD_ID"])) await self.bot.close() return - logger.debug( - "Invite URL: %s", - generate_invite_url( - str(self.bot.application_id), - str(settings["DISCORD_GUILD_ID"])) - ) + if self.bot.application_id: + logger.debug( + "Invite URL: %s", + generate_invite_url( + self.bot.application_id, + settings["DISCORD_GUILD_ID"]) + ) if not discord.utils.get(main_guild.roles, name="Committee"): logger.warning(CommitteeRoleDoesNotExistError()) diff --git a/tests/test_utils.py b/tests/test_utils.py index 69c0fceb1..440d1cf25 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,27 +7,12 @@ import os import random import re -import string -import sys -from argparse import Namespace -from collections.abc import Iterable, Sequence -from pathlib import Path -from types import TracebackType -from typing import TYPE_CHECKING, Final +from collections.abc import Sequence +from typing import Final import pytest -from _pytest.capture import CaptureFixture, CaptureResult -from classproperties import classproperty - -import config -import utils -from utils import InviteURLGenerator, UtilityFunction -if TYPE_CHECKING: - from argparse import ArgumentParser - - # noinspection PyProtectedMember - from argparse import _SubParsersAction as SubParsersAction +from utils import generate_invite_url # TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 # https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 @@ -96,245 +81,25 @@ # ) == f"{time_value:.2f} {TIME_SCALE}s" -class BaseTestArgumentParser: - """Parent class to define the execution code used by all ArgumentParser test cases.""" - - UTILITY_FUNCTIONS: frozenset[type[UtilityFunction]] - - # noinspection PyMethodParameters,PyPep8Naming - @classproperty - def USAGE_MESSAGE(cls) -> str: # noqa: N805,N802 - """The error message describing how the given function should be called.""" # noqa: D401 - return cls._format_usage_message( - utility_function.NAME for utility_function in cls.UTILITY_FUNCTIONS - ) - - @classmethod - def _format_usage_message(cls, utility_function_names: Iterable[str]) -> str: - return f"""usage: utils [-h]{ - " {" if utility_function_names else "" - }{ - "|".join(utility_function_names) - }{ - "}" if utility_function_names else "" - }""" - - @classmethod - def execute_argument_parser_function(cls, args: Sequence[str], capsys: CaptureFixture[str], utility_functions: Iterable[type[UtilityFunction]] | None = None) -> tuple[int, CaptureResult[str]]: # noqa: E501 - """Execute the chosen argument parser function.""" - try: - return_code: int = utils.main( - args, - cls.UTILITY_FUNCTIONS if utility_functions is None else utility_functions - ) - except SystemExit as e: - return_code = 0 if not e.code else int(e.code) - - return return_code, capsys.readouterr() - - class EmptyContextManager: - """Empty context manager that executes no logic when entering/exiting.""" - - def __enter__(self) -> None: - """Enter the context manager and execute no additional logic.""" - - def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType) -> None: # noqa: E501 - """Exit the context manager and execute no additional logic.""" - - class EnvVariableDeleter(EmptyContextManager): - """ - Context manager that deletes the given environment variable. - - The given environment variable is removed from both - the system environment variables list, - and the .env file in this project's root directory. - """ - - def __init__(self, env_variable_name: str) -> None: - """Store the current state of any instances of the stored environment variable.""" - self.env_variable_name: str = env_variable_name - - self.env_file_path: Path = config.PROJECT_ROOT / Path(".env") - self.old_env_value: str | None = os.environ.get(self.env_variable_name) - - def __enter__(self) -> None: - """Delete all stored instances of the stored environment variable.""" - if self.env_file_path.is_file(): - self.env_file_path = self.env_file_path.rename( - config.PROJECT_ROOT / Path(".env.original") - ) - - if self.old_env_value is not None: - del os.environ[self.env_variable_name] - - def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType) -> None: # noqa: E501 - """Restore the deleted environment variable to its previous states.""" - if self.env_file_path.is_file(): - self.env_file_path.rename(config.PROJECT_ROOT / Path(".env")) - - if self.old_env_value is not None: - os.environ[self.env_variable_name] = self.old_env_value - - -class TestMain(BaseTestArgumentParser): - """Test case to unit-test the main argument parser.""" - - UTILITY_FUNCTIONS: frozenset[type[UtilityFunction]] = frozenset() - - @classmethod - def test_error_when_no_function(cls, capsys: CaptureFixture[str]) -> None: - """Test for the correct error when no function name is provided.""" - EXPECTED_ERROR_MESSAGE: Final[str] = ( - "utils: error: the following arguments are required: function" - ) - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function([], capsys) - - assert return_code != 0 - assert not capture_result.out - assert cls.USAGE_MESSAGE in capture_result.err - assert EXPECTED_ERROR_MESSAGE in capture_result.err - - @classmethod - def test_error_when_invalid_function(cls, capsys: CaptureFixture[str]) -> None: - """Test for the correct error when an invalid function name is provided.""" - INVALID_FUNCTION: Final[str] = "".join( - random.choices(string.ascii_letters + string.digits, k=7) - ) - EXPECTED_ERROR_MESSAGE: Final[str] = ( - "utils: error: argument function: invalid choice: " - f"'{INVALID_FUNCTION}' (choose from )" - ) - - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function( - [INVALID_FUNCTION], - capsys - ) - - assert return_code != 0 - assert not capture_result.out - assert cls.USAGE_MESSAGE in capture_result.err - assert EXPECTED_ERROR_MESSAGE in capture_result.err - - @classmethod - @pytest.mark.parametrize( - "help_argument", - ("test_successful_execution", "test_invalid_function_error_message") - ) - def test_attaching_utility_function(cls, capsys: CaptureFixture[str], help_argument: str) -> None: # noqa: E501 - """Test for the correct error when an invalid function name is provided.""" - class ExampleUtilityFunction(UtilityFunction): - NAME: str = "example_utility_function" - DESCRIPTION: str = "An example utility function for testing purposes" - - @classmethod - def run(cls, parsed_args: Namespace, parser: "SubParsersAction[ArgumentParser]") -> int: # noqa: ARG003,E501 - sys.stdout.write("Successful execution\n") - return 0 - - return_code: int - capture_result: CaptureResult[str] - - if help_argument == "test_successful_execution": - return_code, capture_result = cls.execute_argument_parser_function( - [ExampleUtilityFunction.NAME], - capsys, - {ExampleUtilityFunction} - ) - - assert return_code == 0 - assert not capture_result.err - assert capture_result.out.strip() == "Successful execution" - - elif help_argument == "test_invalid_function_error_message": - INVALID_FUNCTION: Final[str] = "".join( - random.choices(string.ascii_letters + string.digits, k=7) - ) - EXPECTED_ERROR_MESSAGE: Final[str] = ( - f"utils: error: argument function: invalid choice: " - f"'{INVALID_FUNCTION}' (choose from {ExampleUtilityFunction.NAME!r})" - ) - - return_code, capture_result = cls.execute_argument_parser_function( - [INVALID_FUNCTION], - capsys, - {ExampleUtilityFunction} - ) - - assert return_code != 0 - assert not capture_result.out - assert ( - cls._format_usage_message({ExampleUtilityFunction.NAME}) - in capture_result.err - ) - assert EXPECTED_ERROR_MESSAGE in capture_result.err - - @classmethod - @pytest.mark.parametrize("help_argument", ("-h", "--help")) - def test_help(cls, capsys: CaptureFixture[str], help_argument: str) -> None: - """Test for the correct response when any of the help arguments are provided.""" - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function( - [help_argument], - capsys - ) - - assert return_code == 0 - assert not capture_result.err - assert cls.USAGE_MESSAGE in capture_result.out - assert "functions:" in capture_result.out - - -class TestInviteURLGenerator(BaseTestArgumentParser): +class TestInviteURLGenerator: """ Test case to unit-test the generate_invite_url utility function component. Includes tests for both the argument parser & low-level URL generation function. """ - UTILITY_FUNCTIONS: frozenset[type[UtilityFunction]] = frozenset({InviteURLGenerator}) - - # noinspection PyMethodParameters,PyPep8Naming - @classproperty - def USAGE_MESSAGE(cls) -> str: # noqa: N805,N802 - """The error message describing how the given function should be called.""" # noqa: D401 - return ( - "usage: utils generate_invite_url [-h] " - "discord_bot_application_id [discord_guild_id]" - ) - - @classmethod - def execute_argument_parser_function(cls, args: Sequence[str], capsys: CaptureFixture[str], utility_functions: Iterable[type[UtilityFunction]] | None = None, *, delete_env_guild_id: bool = True) -> tuple[int, CaptureResult[str]]: # noqa: E501 - """ - Execute the given utility function. - - The command line outputs are stored in class variables for later access. - """ - env_guild_id_deleter: BaseTestArgumentParser.EmptyContextManager = ( - cls.EnvVariableDeleter(env_variable_name="GUILD_ID") - if delete_env_guild_id - else cls.EmptyContextManager() - ) - - with env_guild_id_deleter: - return super().execute_argument_parser_function(args, capsys, utility_functions) - @staticmethod def test_low_level_url_generates() -> None: """Test that the invite URL generates successfully when valid arguments are passed.""" - DISCORD_BOT_APPLICATION_ID: Final[str] = "".join( - random.choices(string.digits, k=random.randint(17, 20)) + DISCORD_BOT_APPLICATION_ID: Final[int] = random.randint( + 10000000000000000, 99999999999999999999 ) DISCORD_GUILD_ID: Final[int] = random.randint( 10000000000000000, 99999999999999999999 ) - invite_url: str = InviteURLGenerator.generate_invite_url( + invite_url: str = generate_invite_url( DISCORD_BOT_APPLICATION_ID, DISCORD_GUILD_ID ) @@ -344,10 +109,10 @@ def test_low_level_url_generates() -> None: ) @classmethod - def test_parser_generates_url_with_discord_guild_id_as_environment_variable(cls, capsys: CaptureFixture[str]) -> None: # noqa: E501 + def test_parser_generates_url_with_discord_guild_id_as_environment_variable(cls) -> None: """Test for the correct response when discord_guild_id is given as an env variable.""" - DISCORD_BOT_APPLICATION_ID: Final[str] = str( - random.randint(10000000000000000, 99999999999999999999) + DISCORD_BOT_APPLICATION_ID: Final[int] = random.randint( + 10000000000000000, 99999999999999999999 ) DISCORD_GUILD_ID: Final[int] = random.randint( 10000000000000000, @@ -357,184 +122,45 @@ def test_parser_generates_url_with_discord_guild_id_as_environment_variable(cls, old_env_discord_guild_id: str = os.environ.get("DISCORD_GUILD_ID", "") os.environ["DISCORD_GUILD_ID"] = str(DISCORD_GUILD_ID) - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function( - ["generate_invite_url", str(DISCORD_BOT_APPLICATION_ID)], - capsys, - delete_env_guild_id=False - ) - - if old_env_discord_guild_id: - os.environ["DISCORD_GUILD_ID"] = old_env_discord_guild_id - else: - del os.environ["DISCORD_GUILD_ID"] - - assert return_code == 0 - assert not capture_result.err - assert capture_result.out.strip() == InviteURLGenerator.generate_invite_url( - DISCORD_BOT_APPLICATION_ID, - DISCORD_GUILD_ID - ) + try: + capture_result: str + capture_result = generate_invite_url( + DISCORD_BOT_APPLICATION_ID, DISCORD_GUILD_ID + ) + if old_env_discord_guild_id: + os.environ["DISCORD_GUILD_ID"] = old_env_discord_guild_id + else: + del os.environ["DISCORD_GUILD_ID"] + assert re.match( + f"https://discord.com/.*={DISCORD_BOT_APPLICATION_ID}.*={DISCORD_GUILD_ID}", + capture_result + ) + except Exception as e: # noqa: BLE001 + if old_env_discord_guild_id: + os.environ["DISCORD_GUILD_ID"] = old_env_discord_guild_id + else: + del os.environ["DISCORD_GUILD_ID"] + pytest.fail(f"generate_invite_url raised error: {e}") @classmethod - def test_parser_generates_url_with_discord_guild_id_as_argument(cls, capsys: CaptureFixture[str]) -> None: # noqa: E501 + def test_parser_generates_url_with_discord_guild_id_as_argument(cls) -> None: """Test for the correct response when discord_guild_id is provided as an argument.""" - DISCORD_BOT_APPLICATION_ID: Final[str] = str( - random.randint(10000000000000000, 99999999999999999999) + DISCORD_BOT_APPLICATION_ID: Final[int] = random.randint( + 10000000000000000, 99999999999999999999 ) DISCORD_GUILD_ID: Final[int] = random.randint( 10000000000000000, 99999999999999999999 ) + try: + capture_result: str + capture_result = generate_invite_url( + DISCORD_BOT_APPLICATION_ID, DISCORD_GUILD_ID + ) + assert re.match( + f"https://discord.com/.*={DISCORD_BOT_APPLICATION_ID}.*={DISCORD_GUILD_ID}", + capture_result + ) + except Exception as e: # noqa: BLE001 + pytest.fail(f"generate_invite_url raised error: {e}") - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function( - ["generate_invite_url", DISCORD_BOT_APPLICATION_ID, str(DISCORD_GUILD_ID)], - capsys - ) - - assert return_code == 0 - assert not capture_result.err - assert capture_result.out.strip() == InviteURLGenerator.generate_invite_url( - DISCORD_BOT_APPLICATION_ID, - DISCORD_GUILD_ID - ) - - @classmethod - def test_parser_error_when_no_discord_bot_application_id(cls, capsys: CaptureFixture[str]) -> None: # noqa: E501 - """Test for the correct error when no discord_bot_application_id is provided.""" - EXPECTED_ERROR_MESSAGE: Final[str] = ( - "utils generate_invite_url: error: the following arguments are required: " - "discord_bot_application_id" - ) - - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function( - ["generate_invite_url"], - capsys - ) - - assert return_code != 0 - assert not capture_result.out - assert cls.USAGE_MESSAGE in " ".join(capture_result.err.replace("\n", "").split()) - assert EXPECTED_ERROR_MESSAGE in capture_result.err - - @classmethod - def test_parser_error_when_invalid_discord_bot_application_id(cls, capsys: CaptureFixture[str]) -> None: # noqa: E501 - """Test for the correct error with an invalid discord_bot_application_id.""" - EXPECTED_ERROR_MESSAGE: Final[str] = ( - "utils generate_invite_url: error: discord_bot_application_id must be " - "a valid Discord application ID " - "(see https://support-dev.discord.com/hc/en-gb/articles/360028717192-Where-can-I-find-my-Application-Team-Server-ID-)" - ) - - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function( - [ - "generate_invite_url", - "".join(random.choices(string.ascii_letters + string.digits, k=7)) - ], - capsys - ) - - assert return_code != 0 - assert not capture_result.out - assert cls.USAGE_MESSAGE in " ".join(capture_result.err.replace("\n", "").split()) - assert EXPECTED_ERROR_MESSAGE in capture_result.err - - @classmethod - def test_parser_error_when_no_discord_guild_id(cls, capsys: CaptureFixture[str]) -> None: - """Test for the correct error when no discord_guild_id is provided.""" - EXPECTED_ERROR_MESSAGE: Final[str] = ( - "utils generate_invite_url: error: discord_guild_id must be provided as an " - "argument to the generate_invite_url utility function or otherwise set " - "the DISCORD_GUILD_ID environment variable" - ) - - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function( - [ - "generate_invite_url", - "".join(random.choices(string.digits, k=random.randint(17, 20))) - ], - capsys, - delete_env_guild_id=True - ) - - assert return_code != 0 - assert not capture_result.out - assert cls.USAGE_MESSAGE in " ".join(capture_result.err.replace("\n", "").split()) - assert EXPECTED_ERROR_MESSAGE in capture_result.err - - @classmethod - def test_parser_error_when_invalid_discord_guild_id(cls, capsys: CaptureFixture[str]) -> None: # noqa: E501 - """Test for the correct error when an invalid discord_guild_id is provided.""" - EXPECTED_ERROR_MESSAGE: Final[str] = ( - "utils generate_invite_url: error: discord_guild_id must be " - "a valid Discord guild ID (see https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id)" - ) - - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function( - [ - "generate_invite_url", - str(random.randint(10000000000000000, 99999999999999999999)), - "".join(random.choices(string.ascii_letters + string.digits, k=7)) - ], - capsys - ) - - assert return_code != 0 - assert not capture_result.out - assert cls.USAGE_MESSAGE in " ".join(capture_result.err.replace("\n", "").split()) - assert EXPECTED_ERROR_MESSAGE in capture_result.err - - @classmethod - def test_parser_error_when_too_many_arguments(cls, capsys: CaptureFixture[str]) -> None: - """Test for the correct error when too many arguments are provided.""" - EXTRA_ARGUMENT: Final[str] = str( - random.randint(10000000000000000, 99999999999999999999) - ) - EXPECTED_ERROR_MESSAGE: Final[str] = ( - "utils: error: " - f"unrecognized arguments: {EXTRA_ARGUMENT}" - ) - - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function( - [ - "generate_invite_url", - str(random.randint(10000000000000000, 99999999999999999999)), - str(random.randint(10000000000000000, 99999999999999999999)), - EXTRA_ARGUMENT - ], - capsys - ) - - assert return_code != 0 - assert not capture_result.out - assert super().USAGE_MESSAGE in capture_result.err - assert EXPECTED_ERROR_MESSAGE in capture_result.err - - @classmethod - @pytest.mark.parametrize("help_argument", ("-h", "--help")) - def test_parser_help(cls, capsys: CaptureFixture[str], help_argument: str) -> None: - """Test for the correct response when any of the help arguments are provided.""" - return_code: int - capture_result: CaptureResult[str] - return_code, capture_result = cls.execute_argument_parser_function( - ["generate_invite_url", help_argument], - capsys - ) - - assert return_code == 0 - assert not capture_result.err - assert cls.USAGE_MESSAGE in " ".join(capture_result.out.replace("\n", "").split()) - assert "positional arguments:" in capture_result.out diff --git a/utils/generate_invite_url.py b/utils/generate_invite_url.py index c59025564..70ad33ef0 100644 --- a/utils/generate_invite_url.py +++ b/utils/generate_invite_url.py @@ -1,26 +1,14 @@ """Utility function to generate the URL to invite the bot to a given Discord guild.""" - -import re from collections.abc import Sequence -import discord - __all__: Sequence[str] = ("generate_invite_url",) -def generate_invite_url(discord_bot_application_id: str, discord_guild_id: str) -> str: - """Execute the logic that this util function provides.""" - discord_bot_application_id = str(discord_bot_application_id) - discord_guild_id = str(discord_guild_id) - - if not discord_guild_id: - err = "discord_guild_id must be set in the DISCORD_GUILD_ID environment variable" - raise ValueError(err) +import discord - if not discord_guild_id or not re.match(r"\A\d{17,20}\Z", discord_guild_id): - err = "discord_guild_id must be a valid Discord guild ID (see https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id)" - raise ValueError(err) +def generate_invite_url(discord_bot_application_id: int, discord_guild_id: int) -> str: + """Execute the logic that this util function provides.""" return discord.utils.oauth_url( client_id=discord_bot_application_id, permissions=discord.Permissions(