From 4f4caf4e427490c43e2a78185afbbb58b438cb2a Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sat, 31 May 2025 21:50:57 +0000 Subject: [PATCH 1/7] Implement minor config fixes --- config.py | 192 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 111 insertions(+), 81 deletions(-) diff --git a/config.py b/config.py index 73812917e..71d438a92 100644 --- a/config.py +++ b/config.py @@ -47,7 +47,7 @@ TRUE_VALUES: "Final[frozenset[str]]" = frozenset({"true", "1", "t", "y", "yes", "on"}) FALSE_VALUES: "Final[frozenset[str]]" = frozenset({"false", "0", "f", "n", "no", "off"}) VALID_SEND_INTRODUCTION_REMINDERS_VALUES: "Final[frozenset[str]]" = frozenset( - {"once"} | TRUE_VALUES | FALSE_VALUES, + {"once"} | TRUE_VALUES | FALSE_VALUES | {"interval"}, ) DEFAULT_STATISTICS_ROLES: "Final[frozenset[str]]" = frozenset( { @@ -136,7 +136,9 @@ def __getitem__(self, item: str) -> "Any": # type: ignore[explicit-any] # noqa @staticmethod def _setup_logging() -> None: - raw_console_log_level: str = str(os.getenv("CONSOLE_LOG_LEVEL", "INFO")).upper() + raw_console_log_level: str = ( + str(os.getenv("CONSOLE_LOG_LEVEL", "INFO")).upper().strip() + ) if raw_console_log_level not in LOG_LEVEL_CHOICES: INVALID_LOG_LEVEL_MESSAGE: Final[str] = f"""LOG_LEVEL must be one of { @@ -158,15 +160,15 @@ def _setup_logging() -> None: @classmethod def _setup_discord_bot_token(cls) -> None: - raw_discord_bot_token: str | None = os.getenv("DISCORD_BOT_TOKEN") + raw_discord_bot_token: str = os.getenv("DISCORD_BOT_TOKEN", default="").strip() DISCORD_BOT_TOKEN_IS_VALID: Final[bool] = bool( - raw_discord_bot_token - and re.fullmatch( + re.fullmatch( r"\A([A-Za-z0-9_-]{24,26})\.([A-Za-z0-9_-]{6})\.([A-Za-z0-9_-]{27,38})\Z", raw_discord_bot_token, ), ) + if not DISCORD_BOT_TOKEN_IS_VALID: INVALID_DISCORD_BOT_TOKEN_MESSAGE: Final[str] = ( "DISCORD_BOT_TOKEN must be a valid Discord bot token " # noqa: S105 @@ -180,13 +182,12 @@ def _setup_discord_bot_token(cls) -> None: def _setup_discord_log_channel_webhook(cls) -> "Logger": raw_discord_log_channel_webhook_url: str = os.getenv( "DISCORD_LOG_CHANNEL_WEBHOOK_URL", "" - ) + ).strip() - if raw_discord_log_channel_webhook_url and ( - not validators.url(raw_discord_log_channel_webhook_url) - or not raw_discord_log_channel_webhook_url.startswith( - "https://discord.com/api/webhooks/" - ) + if not validators.url( + raw_discord_log_channel_webhook_url + ) or not raw_discord_log_channel_webhook_url.startswith( + "https://discord.com/api/webhooks/" ): INVALID_DISCORD_LOG_CHANNEL_WEBHOOK_URL_MESSAGE: Final[str] = ( "DISCORD_LOG_CHANNEL_WEBHOOK_URL must be a valid webhook URL " @@ -215,11 +216,12 @@ def _setup_discord_log_channel_webhook(cls) -> "Logger": @classmethod def _setup_discord_guild_id(cls) -> None: - raw_discord_guild_id: str | None = os.getenv("DISCORD_GUILD_ID") + raw_discord_guild_id: str = os.getenv("DISCORD_GUILD_ID", default="").strip() DISCORD_GUILD_ID_IS_VALID: Final[bool] = bool( - raw_discord_guild_id and re.fullmatch(r"\A\d{17,20}\Z", raw_discord_guild_id), + re.fullmatch(r"\A\d{17,20}\Z", raw_discord_guild_id), ) + if not DISCORD_GUILD_ID_IS_VALID: INVALID_DISCORD_GUILD_ID_MESSAGE: Final[str] = ( "DISCORD_GUILD_ID must be a valid Discord guild ID " @@ -227,14 +229,11 @@ def _setup_discord_guild_id(cls) -> None: ) raise ImproperlyConfiguredError(INVALID_DISCORD_GUILD_ID_MESSAGE) - cls._settings["_DISCORD_MAIN_GUILD_ID"] = int(raw_discord_guild_id) # type: ignore[arg-type] + cls._settings["_DISCORD_MAIN_GUILD_ID"] = int(raw_discord_guild_id) @classmethod def _setup_group_full_name(cls) -> None: - raw_group_full_name: str | None = os.getenv("GROUP_NAME") - - if raw_group_full_name is not None: - raw_group_full_name = raw_group_full_name.strip() + raw_group_full_name: str = os.getenv("GROUP_NAME", default="").strip() if not raw_group_full_name: cls._settings["_GROUP_FULL_NAME"] = None @@ -250,10 +249,7 @@ def _setup_group_full_name(cls) -> None: @classmethod def _setup_group_short_name(cls) -> None: - raw_group_short_name: str | None = os.getenv("GROUP_SHORT_NAME") - - if raw_group_short_name is not None: - raw_group_short_name = raw_group_short_name.strip() + raw_group_short_name: str = os.getenv("GROUP_SHORT_NAME", default="").strip() if not raw_group_short_name: cls._settings["_GROUP_SHORT_NAME"] = None @@ -269,15 +265,17 @@ def _setup_group_short_name(cls) -> None: @classmethod def _setup_purchase_membership_url(cls) -> None: - raw_purchase_membership_url: str | None = os.getenv("PURCHASE_MEMBERSHIP_URL") - - if raw_purchase_membership_url is not None: - raw_purchase_membership_url = raw_purchase_membership_url.strip() + raw_purchase_membership_url: str = os.getenv( + "PURCHASE_MEMBERSHIP_URL", default="" + ).strip() if not raw_purchase_membership_url: cls._settings["PURCHASE_MEMBERSHIP_URL"] = None return + if "://" not in raw_purchase_membership_url: + raw_purchase_membership_url = "https://" + raw_purchase_membership_url + if not validators.url(raw_purchase_membership_url): INVALID_PURCHASE_MEMBERSHIP_URL_MESSAGE: Final[str] = ( "PURCHASE_MEMBERSHIP_URL must be a valid URL." @@ -288,15 +286,15 @@ def _setup_purchase_membership_url(cls) -> None: @classmethod def _setup_membership_perks_url(cls) -> None: - raw_membership_perks_url: str | None = os.getenv("MEMBERSHIP_PERKS_URL") - - if raw_membership_perks_url is not None: - raw_membership_perks_url = raw_membership_perks_url.strip() + raw_membership_perks_url: str = os.getenv("MEMBERSHIP_PERKS_URL", default="").strip() if not raw_membership_perks_url: cls._settings["MEMBERSHIP_PERKS_URL"] = None return + if "://" not in raw_membership_perks_url: + raw_membership_perks_url = "https://" + raw_membership_perks_url + if not validators.url(raw_membership_perks_url): INVALID_MEMBERSHIP_PERKS_URL_MESSAGE: Final[str] = ( "MEMBERSHIP_PERKS_URL must be a valid URL." @@ -307,15 +305,17 @@ def _setup_membership_perks_url(cls) -> None: @classmethod def _setup_custom_discord_invite_url(cls) -> None: - raw_custom_discord_invite_url: str | None = os.getenv("CUSTOM_DISCORD_INVITE_URL") - - if raw_custom_discord_invite_url is not None: - raw_custom_discord_invite_url = raw_custom_discord_invite_url.strip() + raw_custom_discord_invite_url: str = os.getenv( + "CUSTOM_DISCORD_INVITE_URL", default="" + ).strip() if not raw_custom_discord_invite_url: cls._settings["CUSTOM_DISCORD_INVITE_URL"] = None return + if "://" not in raw_custom_discord_invite_url: + raw_custom_discord_invite_url = "https://" + raw_custom_discord_invite_url + if not validators.url(raw_custom_discord_invite_url): INVALID_CUSTOM_DISCORD_INVITE_URL_MESSAGE: Final[str] = ( "CUSTOM_DISCORD_INVITE_URL must be a valid URL." @@ -326,14 +326,10 @@ def _setup_custom_discord_invite_url(cls) -> None: @classmethod def _setup_ping_command_easter_egg_probability(cls) -> None: - raw_ping_command_easter_egg_probability_string: str | None = os.getenv( - "PING_COMMAND_EASTER_EGG_PROBABILITY" - ) - - if raw_ping_command_easter_egg_probability_string is not None: - raw_ping_command_easter_egg_probability_string = ( - raw_ping_command_easter_egg_probability_string.strip() - ) + raw_ping_command_easter_egg_probability_string: str = os.getenv( + "PING_COMMAND_EASTER_EGG_PROBABILITY", + default="", + ).strip() if not raw_ping_command_easter_egg_probability_string: cls._settings["PING_COMMAND_EASTER_EGG_PROBABILITY"] = 1 @@ -371,7 +367,7 @@ def _get_messages_dict(cls, raw_messages_file_path: str | None) -> Mapping[str, ) messages_file_path: Path = ( - Path(raw_messages_file_path) + Path(raw_messages_file_path.strip()) if raw_messages_file_path else PROJECT_ROOT / Path("messages.json") ) @@ -438,10 +434,10 @@ def _setup_roles_messages(cls) -> None: @classmethod def _setup_organisation_id(cls) -> None: - raw_organisation_id: str | None = os.getenv("ORGANISATION_ID") + raw_organisation_id: str = os.getenv("ORGANISATION_ID", default="").strip() ORGANISATION_ID_IS_VALID: Final[bool] = bool( - raw_organisation_id and re.fullmatch(r"\A\d{4,5}\Z", raw_organisation_id), + re.fullmatch(r"\A\d{4,5}\Z", raw_organisation_id), ) if not ORGANISATION_ID_IS_VALID: @@ -454,14 +450,15 @@ def _setup_organisation_id(cls) -> None: @classmethod def _setup_members_list_auth_session_cookie(cls) -> None: - raw_members_list_auth_session_cookie: str | None = os.getenv( + raw_members_list_auth_session_cookie: str = os.getenv( "MEMBERS_LIST_URL_SESSION_COOKIE", - ) + default="", + ).strip() MEMBERS_LIST_AUTH_SESSION_COOKIE_IS_VALID: Final[bool] = bool( - raw_members_list_auth_session_cookie - and re.fullmatch(r"\A[A-Fa-f\d]{128,256}\Z", raw_members_list_auth_session_cookie), + re.fullmatch(r"\A[A-Fa-f\d]{128,256}\Z", raw_members_list_auth_session_cookie), ) + if not MEMBERS_LIST_AUTH_SESSION_COOKIE_IS_VALID: INVALID_MEMBERS_LIST_AUTH_SESSION_COOKIE_MESSAGE: Final[str] = ( "MEMBERS_LIST_URL_SESSION_COOKIE must be a valid .ASPXAUTH cookie." @@ -474,9 +471,9 @@ def _setup_members_list_auth_session_cookie(cls) -> None: @classmethod def _setup_send_introduction_reminders(cls) -> None: - raw_send_introduction_reminders: str | bool = str( - os.getenv("SEND_INTRODUCTION_REMINDERS", "Once"), - ).lower() + raw_send_introduction_reminders: str | bool = ( + str(os.getenv("SEND_INTRODUCTION_REMINDERS", "Once")).lower().strip() + ) if raw_send_introduction_reminders not in VALID_SEND_INTRODUCTION_REMINDERS_VALUES: INVALID_SEND_INTRODUCTION_REMINDERS_MESSAGE: Final[str] = ( @@ -503,7 +500,9 @@ def _setup_send_introduction_reminders_delay(cls) -> None: raw_send_introduction_reminders_delay: re.Match[str] | None = re.fullmatch( r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?(?:(?P(?:\d*\.)?\d+)d)?(?:(?P(?:\d*\.)?\d+)w)?\Z", - str(os.getenv("SEND_INTRODUCTION_REMINDERS_DELAY", "40h")), + str( + os.getenv("SEND_INTRODUCTION_REMINDERS_DELAY", "40h").strip().replace(" ", "") + ), ) raw_timedelta_send_introduction_reminders_delay: timedelta = timedelta() @@ -528,8 +527,7 @@ def _setup_send_introduction_reminders_delay(cls) -> None: if raw_timedelta_send_introduction_reminders_delay < timedelta(days=1): TOO_SMALL_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_INTRODUCTION_REMINDERS_DELAY must be longer than or equal to 1 day " - "(in any allowed format)." + "SEND_INTRODUCTION_REMINDERS_DELAY must be longer than or equal to 1 day." ) raise ImproperlyConfiguredError( TOO_SMALL_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE, @@ -550,7 +548,11 @@ def _setup_send_introduction_reminders_interval(cls) -> None: raw_send_introduction_reminders_interval: re.Match[str] | None = re.fullmatch( r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?\Z", - str(os.getenv("SEND_INTRODUCTION_REMINDERS_INTERVAL", "6h")), + str( + os.getenv("SEND_INTRODUCTION_REMINDERS_INTERVAL", "6h") + .strip() + .replace(" ", "") + ), ) raw_timedelta_details_send_introduction_reminders_interval: Mapping[str, float] = { @@ -573,15 +575,28 @@ def _setup_send_introduction_reminders_interval(cls) -> None: if value } + if ( + timedelta( + **raw_timedelta_details_send_introduction_reminders_interval + ).total_seconds() + <= 3 + ): + TOO_SMALL_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE: Final[str] = ( + "SEND_INTRODUCTION_REMINDERS_INTERVAL must be longer than 3 seconds." + ) + raise ImproperlyConfiguredError( + TOO_SMALL_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE, + ) + cls._settings["SEND_INTRODUCTION_REMINDERS_INTERVAL"] = ( raw_timedelta_details_send_introduction_reminders_interval ) @classmethod def _setup_send_get_roles_reminders(cls) -> None: - raw_send_get_roles_reminders: str = str( - os.getenv("SEND_GET_ROLES_REMINDERS", "True"), - ).lower() + raw_send_get_roles_reminders: str = ( + str(os.getenv("SEND_GET_ROLES_REMINDERS", "True")).lower().strip() + ) if raw_send_get_roles_reminders not in TRUE_VALUES | FALSE_VALUES: INVALID_SEND_GET_ROLES_REMINDERS_MESSAGE: Final[str] = ( @@ -602,7 +617,7 @@ def _setup_send_get_roles_reminders_delay(cls) -> None: raw_send_get_roles_reminders_delay: re.Match[str] | None = re.fullmatch( r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?(?:(?P(?:\d*\.)?\d+)d)?(?:(?P(?:\d*\.)?\d+)w)?\Z", - str(os.getenv("SEND_GET_ROLES_REMINDERS_DELAY", "40h")), + str(os.getenv("SEND_GET_ROLES_REMINDERS_DELAY", "40h").strip().replace(" ", "")), ) raw_timedelta_send_get_roles_reminders_delay: timedelta = timedelta() @@ -627,8 +642,8 @@ def _setup_send_get_roles_reminders_delay(cls) -> None: if raw_timedelta_send_get_roles_reminders_delay < timedelta(days=1): TOO_SMALL_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_SEND_GET_ROLES_REMINDERS_DELAY " - "must be longer than or equal to 1 day (in any allowed format)." + "SEND_SEND_GET_ROLES_REMINDERS_DELAY must be " + "longer than or equal to 1 day." ) raise ImproperlyConfiguredError( TOO_SMALL_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE, @@ -649,14 +664,16 @@ def _setup_advanced_send_get_roles_reminders_interval(cls) -> None: raw_advanced_send_get_roles_reminders_interval: re.Match[str] | None = re.fullmatch( r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?\Z", - str(os.getenv("ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL", "24h")), + str( + os.getenv("ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL", "24h") + .strip() + .replace(" ", "") + ), ) raw_timedelta_details_advanced_send_get_roles_reminders_interval: Mapping[ str, float - ] = { - "hours": 24, - } + ] = {"hours": 24} if cls._settings["SEND_GET_ROLES_REMINDERS"]: if not raw_advanced_send_get_roles_reminders_interval: @@ -684,36 +701,48 @@ def _setup_advanced_send_get_roles_reminders_interval(cls) -> None: def _setup_statistics_days(cls) -> None: e: ValueError try: - raw_statistics_days: float = float(os.getenv("STATISTICS_DAYS", "30")) + raw_statistics_days: float = float(os.getenv("STATISTICS_DAYS", "30").strip()) except ValueError as e: INVALID_STATISTICS_DAYS_MESSAGE: Final[str] = ( "STATISTICS_DAYS must contain the statistics period in days." ) raise ImproperlyConfiguredError(INVALID_STATISTICS_DAYS_MESSAGE) from e + if raw_statistics_days < 1: + TOO_SMALL_STATISTICS_DAYS_MESSAGE: Final[str] = ( + "STATISTICS_DAYS cannot be less than or equal to 1 day" + ) + raise ImproperlyConfiguredError(TOO_SMALL_STATISTICS_DAYS_MESSAGE) + cls._settings["STATISTICS_DAYS"] = timedelta(days=raw_statistics_days) @classmethod def _setup_statistics_roles(cls) -> None: - raw_statistics_roles: str | None = os.getenv("STATISTICS_ROLES") + raw_statistics_roles: str = os.getenv("STATISTICS_ROLES", default="").strip() if not raw_statistics_roles: cls._settings["STATISTICS_ROLES"] = DEFAULT_STATISTICS_ROLES + return - else: - cls._settings["STATISTICS_ROLES"] = { - raw_statistics_role - for raw_statistics_role in raw_statistics_roles.split(",") - if raw_statistics_role - } + cls._settings["STATISTICS_ROLES"] = { + raw_statistics_role.strip() + for raw_statistics_role in raw_statistics_roles.split(",") + if raw_statistics_role + } @classmethod def _setup_moderation_document_url(cls) -> None: - raw_moderation_document_url: str | None = os.getenv("MODERATION_DOCUMENT_URL") + raw_moderation_document_url: str = os.getenv( + "MODERATION_DOCUMENT_URL", default="" + ).strip() + + if raw_moderation_document_url and "://" not in raw_moderation_document_url: + raw_moderation_document_url = "https://" + raw_moderation_document_url MODERATION_DOCUMENT_URL_IS_VALID: Final[bool] = bool( - raw_moderation_document_url and validators.url(raw_moderation_document_url), + validators.url(raw_moderation_document_url), ) + if not MODERATION_DOCUMENT_URL_IS_VALID: MODERATION_DOCUMENT_URL_MESSAGE: Final[str] = ( "MODERATION_DOCUMENT_URL must be a valid URL." @@ -726,8 +755,9 @@ def _setup_moderation_document_url(cls) -> None: def _setup_strike_performed_manually_warning_location(cls) -> None: raw_strike_performed_manually_warning_location: str = os.getenv( "MANUAL_MODERATION_WARNING_MESSAGE_LOCATION", - "DM", - ) + default="DM", + ).strip() + if not raw_strike_performed_manually_warning_location: STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION_MESSAGE: Final[str] = ( "MANUAL_MODERATION_WARNING_MESSAGE_LOCATION must be a valid name " @@ -741,9 +771,9 @@ def _setup_strike_performed_manually_warning_location(cls) -> None: @classmethod def _setup_auto_add_committee_to_threads(cls) -> None: - raw_auto_add_committee_to_threads: str = str( - os.getenv("AUTO_ADD_COMMITTEE_TO_THREADS", "True") - ).lower() + raw_auto_add_committee_to_threads: str = ( + str(os.getenv("AUTO_ADD_COMMITTEE_TO_THREADS", "True")).lower().strip() + ) if raw_auto_add_committee_to_threads not in TRUE_VALUES | FALSE_VALUES: INVALID_AUTO_ADD_COMMITTEE_TO_THREADS_MESSAGE: Final[str] = ( From b644f3a828dda4b9c112f61ab055b45a48ef8050 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sat, 31 May 2025 22:07:48 +0000 Subject: [PATCH 2/7] Implement minor workflow fixes --- .github/workflows/check-build-deploy.yaml | 16 ++++- pyproject.toml | 8 ++- uv.lock | 78 ++++++++++++++++++++++- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-build-deploy.yaml b/.github/workflows/check-build-deploy.yaml index 80cd96e2b..bbda5f820 100644 --- a/.github/workflows/check-build-deploy.yaml +++ b/.github/workflows/check-build-deploy.yaml @@ -44,7 +44,7 @@ jobs: enable-cache: true - name: Install mypy From Locked Dependencies - run: uv sync --no-group dev --group type-check + run: uv sync --no-group dev --group type-check --group test - name: Store Hashed Python Version id: store-hashed-python-version @@ -165,7 +165,19 @@ jobs: key: pytest|${{steps.store-hashed-python-version.outputs.hashed_python_version}} - name: Run pytest - run: uv run -- pytest # TODO: Add GitHub workflows output format + run: uv run pytest --cov --cov-branch --cov-report=xml --junitxml=junit.xml + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload coverage report to Codecov + uses: codecov/codecov-action@v5 + if: ${{ !cancelled() }} + with: + token: ${{ secrets.CODECOV_TOKEN }} ruff-lint: runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index 4facdcfae..3b89c9357 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ main = [ "validators>=0.34", ] pre-commit = ["pre-commit>=4.0"] -test = ["pytest>=8.3"] +test = ["gitpython>=3.1.44", "pytest>=8.3", "pytest-cov>=6.1.1"] type-check = ["django-stubs[compatible-mypy]>=5.1", "mypy>=1.13", "types-beautifulsoup4>=4.12"] [project] # TODO: Remove [project] table once https://github.com/astral-sh/uv/issues/8582 is completed @@ -207,6 +207,12 @@ parametrize-values-type = "tuple" keep-runtime-typing = true +[tool.coverage.report] +exclude_also = ["if TYPE_CHECKING:"] +skip_covered = true +sort = "cover" + + [tool.pymarkdown] extensions.front-matter.enabled = true mode.strict-config = true diff --git a/uv.lock b/uv.lock index 958755141..1e0a0bbb4 100644 --- a/uv.lock +++ b/uv.lock @@ -219,6 +219,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, ] +[[package]] +name = "coverage" +version = "7.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/998afa4a0ecdf9b1981ae05415dad2d4e7716e1b1f00abbd91691ac09ac9/coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27", size = 812759, upload-time = "2025-05-23T11:39:57.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/2a/1da1ada2e3044fcd4a3254fb3576e160b8fe5b36d705c8a31f793423f763/coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c", size = 211876, upload-time = "2025-05-23T11:38:29.01Z" }, + { url = "https://files.pythonhosted.org/packages/70/e9/3d715ffd5b6b17a8be80cd14a8917a002530a99943cc1939ad5bb2aa74b9/coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1", size = 212130, upload-time = "2025-05-23T11:38:30.675Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/fdce62bb3c21649abfd91fbdcf041fb99be0d728ff00f3f9d54d97ed683e/coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279", size = 246176, upload-time = "2025-05-23T11:38:32.395Z" }, + { url = "https://files.pythonhosted.org/packages/a7/52/decbbed61e03b6ffe85cd0fea360a5e04a5a98a7423f292aae62423b8557/coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99", size = 243068, upload-time = "2025-05-23T11:38:33.989Z" }, + { url = "https://files.pythonhosted.org/packages/38/6c/d0e9c0cce18faef79a52778219a3c6ee8e336437da8eddd4ab3dbd8fadff/coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20", size = 245328, upload-time = "2025-05-23T11:38:35.568Z" }, + { url = "https://files.pythonhosted.org/packages/f0/70/f703b553a2f6b6c70568c7e398ed0789d47f953d67fbba36a327714a7bca/coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2", size = 245099, upload-time = "2025-05-23T11:38:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fb/4cbb370dedae78460c3aacbdad9d249e853f3bc4ce5ff0e02b1983d03044/coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57", size = 243314, upload-time = "2025-05-23T11:38:39.238Z" }, + { url = "https://files.pythonhosted.org/packages/39/9f/1afbb2cb9c8699b8bc38afdce00a3b4644904e6a38c7bf9005386c9305ec/coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f", size = 244489, upload-time = "2025-05-23T11:38:40.845Z" }, + { url = "https://files.pythonhosted.org/packages/79/fa/f3e7ec7d220bff14aba7a4786ae47043770cbdceeea1803083059c878837/coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8", size = 214366, upload-time = "2025-05-23T11:38:43.551Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/9cbeade19b7e8e853e7ffc261df885d66bf3a782c71cba06c17df271f9e6/coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223", size = 215165, upload-time = "2025-05-23T11:38:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/e2528bf1237d2448f882bbebaec5c3500ef07301816c5c63464b9da4d88a/coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f", size = 213548, upload-time = "2025-05-23T11:38:46.74Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/0b9c32220ad694d66062f571cc5cedfa9997b64a591e8a500bb63de1bd40/coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32", size = 203623, upload-time = "2025-05-23T11:39:53.846Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -358,6 +378,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/3e/b04a0adda73bd52b390d730071c0d577073d3d26740ee1bad25c3ad0f37b/frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", size = 12404, upload-time = "2025-04-17T22:38:51.668Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196, upload-time = "2025-01-02T07:32:43.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599, upload-time = "2025-01-02T07:32:40.731Z" }, +] + [[package]] name = "identify" version = "2.6.10" @@ -695,6 +739,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] +[[package]] +name = "pytest-cov" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857, upload-time = "2025-04-05T14:07:51.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -794,6 +851,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + [[package]] name = "soupsieve" version = "2.7" @@ -821,9 +887,11 @@ source = { virtual = "." } dev = [ { name = "ccft-pymarkdown" }, { name = "django-stubs", extra = ["compatible-mypy"] }, + { name = "gitpython" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, { name = "types-beautifulsoup4" }, ] @@ -849,7 +917,9 @@ pre-commit = [ { name = "pre-commit" }, ] test = [ + { name = "gitpython" }, { name = "pytest" }, + { name = "pytest-cov" }, ] type-check = [ { name = "django-stubs", extra = ["compatible-mypy"] }, @@ -863,9 +933,11 @@ type-check = [ dev = [ { name = "ccft-pymarkdown", specifier = ">=2.0" }, { name = "django-stubs", extras = ["compatible-mypy"], specifier = ">=5.1" }, + { name = "gitpython", specifier = ">=3.1.44" }, { name = "mypy", specifier = ">=1.13" }, { name = "pre-commit", specifier = ">=4.0" }, { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "ruff", specifier = ">=0.9" }, { name = "types-beautifulsoup4", specifier = ">=4.12" }, ] @@ -888,7 +960,11 @@ main = [ { name = "validators", specifier = ">=0.34" }, ] pre-commit = [{ name = "pre-commit", specifier = ">=4.0" }] -test = [{ name = "pytest", specifier = ">=8.3" }] +test = [ + { name = "gitpython", specifier = ">=3.1.44" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, +] type-check = [ { name = "django-stubs", extras = ["compatible-mypy"], specifier = ">=5.1" }, { name = "mypy", specifier = ">=1.13" }, From 2567f2b0640bea3224d7aedabed8208457ad38f4 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sat, 31 May 2025 22:11:26 +0000 Subject: [PATCH 3/7] fix stuff --- config.py | 192 +++++++++++++++++++++++------------------------------- 1 file changed, 81 insertions(+), 111 deletions(-) diff --git a/config.py b/config.py index 71d438a92..73812917e 100644 --- a/config.py +++ b/config.py @@ -47,7 +47,7 @@ TRUE_VALUES: "Final[frozenset[str]]" = frozenset({"true", "1", "t", "y", "yes", "on"}) FALSE_VALUES: "Final[frozenset[str]]" = frozenset({"false", "0", "f", "n", "no", "off"}) VALID_SEND_INTRODUCTION_REMINDERS_VALUES: "Final[frozenset[str]]" = frozenset( - {"once"} | TRUE_VALUES | FALSE_VALUES | {"interval"}, + {"once"} | TRUE_VALUES | FALSE_VALUES, ) DEFAULT_STATISTICS_ROLES: "Final[frozenset[str]]" = frozenset( { @@ -136,9 +136,7 @@ def __getitem__(self, item: str) -> "Any": # type: ignore[explicit-any] # noqa @staticmethod def _setup_logging() -> None: - raw_console_log_level: str = ( - str(os.getenv("CONSOLE_LOG_LEVEL", "INFO")).upper().strip() - ) + raw_console_log_level: str = str(os.getenv("CONSOLE_LOG_LEVEL", "INFO")).upper() if raw_console_log_level not in LOG_LEVEL_CHOICES: INVALID_LOG_LEVEL_MESSAGE: Final[str] = f"""LOG_LEVEL must be one of { @@ -160,15 +158,15 @@ def _setup_logging() -> None: @classmethod def _setup_discord_bot_token(cls) -> None: - raw_discord_bot_token: str = os.getenv("DISCORD_BOT_TOKEN", default="").strip() + raw_discord_bot_token: str | None = os.getenv("DISCORD_BOT_TOKEN") DISCORD_BOT_TOKEN_IS_VALID: Final[bool] = bool( - re.fullmatch( + raw_discord_bot_token + and re.fullmatch( r"\A([A-Za-z0-9_-]{24,26})\.([A-Za-z0-9_-]{6})\.([A-Za-z0-9_-]{27,38})\Z", raw_discord_bot_token, ), ) - if not DISCORD_BOT_TOKEN_IS_VALID: INVALID_DISCORD_BOT_TOKEN_MESSAGE: Final[str] = ( "DISCORD_BOT_TOKEN must be a valid Discord bot token " # noqa: S105 @@ -182,12 +180,13 @@ def _setup_discord_bot_token(cls) -> None: def _setup_discord_log_channel_webhook(cls) -> "Logger": raw_discord_log_channel_webhook_url: str = os.getenv( "DISCORD_LOG_CHANNEL_WEBHOOK_URL", "" - ).strip() + ) - if not validators.url( - raw_discord_log_channel_webhook_url - ) or not raw_discord_log_channel_webhook_url.startswith( - "https://discord.com/api/webhooks/" + if raw_discord_log_channel_webhook_url and ( + not validators.url(raw_discord_log_channel_webhook_url) + or not raw_discord_log_channel_webhook_url.startswith( + "https://discord.com/api/webhooks/" + ) ): INVALID_DISCORD_LOG_CHANNEL_WEBHOOK_URL_MESSAGE: Final[str] = ( "DISCORD_LOG_CHANNEL_WEBHOOK_URL must be a valid webhook URL " @@ -216,12 +215,11 @@ def _setup_discord_log_channel_webhook(cls) -> "Logger": @classmethod def _setup_discord_guild_id(cls) -> None: - raw_discord_guild_id: str = os.getenv("DISCORD_GUILD_ID", default="").strip() + raw_discord_guild_id: str | None = os.getenv("DISCORD_GUILD_ID") DISCORD_GUILD_ID_IS_VALID: Final[bool] = bool( - re.fullmatch(r"\A\d{17,20}\Z", raw_discord_guild_id), + raw_discord_guild_id and re.fullmatch(r"\A\d{17,20}\Z", raw_discord_guild_id), ) - if not DISCORD_GUILD_ID_IS_VALID: INVALID_DISCORD_GUILD_ID_MESSAGE: Final[str] = ( "DISCORD_GUILD_ID must be a valid Discord guild ID " @@ -229,11 +227,14 @@ def _setup_discord_guild_id(cls) -> None: ) raise ImproperlyConfiguredError(INVALID_DISCORD_GUILD_ID_MESSAGE) - cls._settings["_DISCORD_MAIN_GUILD_ID"] = int(raw_discord_guild_id) + cls._settings["_DISCORD_MAIN_GUILD_ID"] = int(raw_discord_guild_id) # type: ignore[arg-type] @classmethod def _setup_group_full_name(cls) -> None: - raw_group_full_name: str = os.getenv("GROUP_NAME", default="").strip() + raw_group_full_name: str | None = os.getenv("GROUP_NAME") + + if raw_group_full_name is not None: + raw_group_full_name = raw_group_full_name.strip() if not raw_group_full_name: cls._settings["_GROUP_FULL_NAME"] = None @@ -249,7 +250,10 @@ def _setup_group_full_name(cls) -> None: @classmethod def _setup_group_short_name(cls) -> None: - raw_group_short_name: str = os.getenv("GROUP_SHORT_NAME", default="").strip() + raw_group_short_name: str | None = os.getenv("GROUP_SHORT_NAME") + + if raw_group_short_name is not None: + raw_group_short_name = raw_group_short_name.strip() if not raw_group_short_name: cls._settings["_GROUP_SHORT_NAME"] = None @@ -265,17 +269,15 @@ def _setup_group_short_name(cls) -> None: @classmethod def _setup_purchase_membership_url(cls) -> None: - raw_purchase_membership_url: str = os.getenv( - "PURCHASE_MEMBERSHIP_URL", default="" - ).strip() + raw_purchase_membership_url: str | None = os.getenv("PURCHASE_MEMBERSHIP_URL") + + if raw_purchase_membership_url is not None: + raw_purchase_membership_url = raw_purchase_membership_url.strip() if not raw_purchase_membership_url: cls._settings["PURCHASE_MEMBERSHIP_URL"] = None return - if "://" not in raw_purchase_membership_url: - raw_purchase_membership_url = "https://" + raw_purchase_membership_url - if not validators.url(raw_purchase_membership_url): INVALID_PURCHASE_MEMBERSHIP_URL_MESSAGE: Final[str] = ( "PURCHASE_MEMBERSHIP_URL must be a valid URL." @@ -286,15 +288,15 @@ def _setup_purchase_membership_url(cls) -> None: @classmethod def _setup_membership_perks_url(cls) -> None: - raw_membership_perks_url: str = os.getenv("MEMBERSHIP_PERKS_URL", default="").strip() + raw_membership_perks_url: str | None = os.getenv("MEMBERSHIP_PERKS_URL") + + if raw_membership_perks_url is not None: + raw_membership_perks_url = raw_membership_perks_url.strip() if not raw_membership_perks_url: cls._settings["MEMBERSHIP_PERKS_URL"] = None return - if "://" not in raw_membership_perks_url: - raw_membership_perks_url = "https://" + raw_membership_perks_url - if not validators.url(raw_membership_perks_url): INVALID_MEMBERSHIP_PERKS_URL_MESSAGE: Final[str] = ( "MEMBERSHIP_PERKS_URL must be a valid URL." @@ -305,17 +307,15 @@ def _setup_membership_perks_url(cls) -> None: @classmethod def _setup_custom_discord_invite_url(cls) -> None: - raw_custom_discord_invite_url: str = os.getenv( - "CUSTOM_DISCORD_INVITE_URL", default="" - ).strip() + raw_custom_discord_invite_url: str | None = os.getenv("CUSTOM_DISCORD_INVITE_URL") + + if raw_custom_discord_invite_url is not None: + raw_custom_discord_invite_url = raw_custom_discord_invite_url.strip() if not raw_custom_discord_invite_url: cls._settings["CUSTOM_DISCORD_INVITE_URL"] = None return - if "://" not in raw_custom_discord_invite_url: - raw_custom_discord_invite_url = "https://" + raw_custom_discord_invite_url - if not validators.url(raw_custom_discord_invite_url): INVALID_CUSTOM_DISCORD_INVITE_URL_MESSAGE: Final[str] = ( "CUSTOM_DISCORD_INVITE_URL must be a valid URL." @@ -326,10 +326,14 @@ def _setup_custom_discord_invite_url(cls) -> None: @classmethod def _setup_ping_command_easter_egg_probability(cls) -> None: - raw_ping_command_easter_egg_probability_string: str = os.getenv( - "PING_COMMAND_EASTER_EGG_PROBABILITY", - default="", - ).strip() + raw_ping_command_easter_egg_probability_string: str | None = os.getenv( + "PING_COMMAND_EASTER_EGG_PROBABILITY" + ) + + if raw_ping_command_easter_egg_probability_string is not None: + raw_ping_command_easter_egg_probability_string = ( + raw_ping_command_easter_egg_probability_string.strip() + ) if not raw_ping_command_easter_egg_probability_string: cls._settings["PING_COMMAND_EASTER_EGG_PROBABILITY"] = 1 @@ -367,7 +371,7 @@ def _get_messages_dict(cls, raw_messages_file_path: str | None) -> Mapping[str, ) messages_file_path: Path = ( - Path(raw_messages_file_path.strip()) + Path(raw_messages_file_path) if raw_messages_file_path else PROJECT_ROOT / Path("messages.json") ) @@ -434,10 +438,10 @@ def _setup_roles_messages(cls) -> None: @classmethod def _setup_organisation_id(cls) -> None: - raw_organisation_id: str = os.getenv("ORGANISATION_ID", default="").strip() + raw_organisation_id: str | None = os.getenv("ORGANISATION_ID") ORGANISATION_ID_IS_VALID: Final[bool] = bool( - re.fullmatch(r"\A\d{4,5}\Z", raw_organisation_id), + raw_organisation_id and re.fullmatch(r"\A\d{4,5}\Z", raw_organisation_id), ) if not ORGANISATION_ID_IS_VALID: @@ -450,15 +454,14 @@ def _setup_organisation_id(cls) -> None: @classmethod def _setup_members_list_auth_session_cookie(cls) -> None: - raw_members_list_auth_session_cookie: str = os.getenv( + raw_members_list_auth_session_cookie: str | None = os.getenv( "MEMBERS_LIST_URL_SESSION_COOKIE", - default="", - ).strip() + ) MEMBERS_LIST_AUTH_SESSION_COOKIE_IS_VALID: Final[bool] = bool( - re.fullmatch(r"\A[A-Fa-f\d]{128,256}\Z", raw_members_list_auth_session_cookie), + raw_members_list_auth_session_cookie + and re.fullmatch(r"\A[A-Fa-f\d]{128,256}\Z", raw_members_list_auth_session_cookie), ) - if not MEMBERS_LIST_AUTH_SESSION_COOKIE_IS_VALID: INVALID_MEMBERS_LIST_AUTH_SESSION_COOKIE_MESSAGE: Final[str] = ( "MEMBERS_LIST_URL_SESSION_COOKIE must be a valid .ASPXAUTH cookie." @@ -471,9 +474,9 @@ def _setup_members_list_auth_session_cookie(cls) -> None: @classmethod def _setup_send_introduction_reminders(cls) -> None: - raw_send_introduction_reminders: str | bool = ( - str(os.getenv("SEND_INTRODUCTION_REMINDERS", "Once")).lower().strip() - ) + raw_send_introduction_reminders: str | bool = str( + os.getenv("SEND_INTRODUCTION_REMINDERS", "Once"), + ).lower() if raw_send_introduction_reminders not in VALID_SEND_INTRODUCTION_REMINDERS_VALUES: INVALID_SEND_INTRODUCTION_REMINDERS_MESSAGE: Final[str] = ( @@ -500,9 +503,7 @@ def _setup_send_introduction_reminders_delay(cls) -> None: raw_send_introduction_reminders_delay: re.Match[str] | None = re.fullmatch( r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?(?:(?P(?:\d*\.)?\d+)d)?(?:(?P(?:\d*\.)?\d+)w)?\Z", - str( - os.getenv("SEND_INTRODUCTION_REMINDERS_DELAY", "40h").strip().replace(" ", "") - ), + str(os.getenv("SEND_INTRODUCTION_REMINDERS_DELAY", "40h")), ) raw_timedelta_send_introduction_reminders_delay: timedelta = timedelta() @@ -527,7 +528,8 @@ def _setup_send_introduction_reminders_delay(cls) -> None: if raw_timedelta_send_introduction_reminders_delay < timedelta(days=1): TOO_SMALL_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_INTRODUCTION_REMINDERS_DELAY must be longer than or equal to 1 day." + "SEND_INTRODUCTION_REMINDERS_DELAY must be longer than or equal to 1 day " + "(in any allowed format)." ) raise ImproperlyConfiguredError( TOO_SMALL_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE, @@ -548,11 +550,7 @@ def _setup_send_introduction_reminders_interval(cls) -> None: raw_send_introduction_reminders_interval: re.Match[str] | None = re.fullmatch( r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?\Z", - str( - os.getenv("SEND_INTRODUCTION_REMINDERS_INTERVAL", "6h") - .strip() - .replace(" ", "") - ), + str(os.getenv("SEND_INTRODUCTION_REMINDERS_INTERVAL", "6h")), ) raw_timedelta_details_send_introduction_reminders_interval: Mapping[str, float] = { @@ -575,28 +573,15 @@ def _setup_send_introduction_reminders_interval(cls) -> None: if value } - if ( - timedelta( - **raw_timedelta_details_send_introduction_reminders_interval - ).total_seconds() - <= 3 - ): - TOO_SMALL_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE: Final[str] = ( - "SEND_INTRODUCTION_REMINDERS_INTERVAL must be longer than 3 seconds." - ) - raise ImproperlyConfiguredError( - TOO_SMALL_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE, - ) - cls._settings["SEND_INTRODUCTION_REMINDERS_INTERVAL"] = ( raw_timedelta_details_send_introduction_reminders_interval ) @classmethod def _setup_send_get_roles_reminders(cls) -> None: - raw_send_get_roles_reminders: str = ( - str(os.getenv("SEND_GET_ROLES_REMINDERS", "True")).lower().strip() - ) + raw_send_get_roles_reminders: str = str( + os.getenv("SEND_GET_ROLES_REMINDERS", "True"), + ).lower() if raw_send_get_roles_reminders not in TRUE_VALUES | FALSE_VALUES: INVALID_SEND_GET_ROLES_REMINDERS_MESSAGE: Final[str] = ( @@ -617,7 +602,7 @@ def _setup_send_get_roles_reminders_delay(cls) -> None: raw_send_get_roles_reminders_delay: re.Match[str] | None = re.fullmatch( r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?(?:(?P(?:\d*\.)?\d+)d)?(?:(?P(?:\d*\.)?\d+)w)?\Z", - str(os.getenv("SEND_GET_ROLES_REMINDERS_DELAY", "40h").strip().replace(" ", "")), + str(os.getenv("SEND_GET_ROLES_REMINDERS_DELAY", "40h")), ) raw_timedelta_send_get_roles_reminders_delay: timedelta = timedelta() @@ -642,8 +627,8 @@ def _setup_send_get_roles_reminders_delay(cls) -> None: if raw_timedelta_send_get_roles_reminders_delay < timedelta(days=1): TOO_SMALL_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_SEND_GET_ROLES_REMINDERS_DELAY must be " - "longer than or equal to 1 day." + "SEND_SEND_GET_ROLES_REMINDERS_DELAY " + "must be longer than or equal to 1 day (in any allowed format)." ) raise ImproperlyConfiguredError( TOO_SMALL_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE, @@ -664,16 +649,14 @@ def _setup_advanced_send_get_roles_reminders_interval(cls) -> None: raw_advanced_send_get_roles_reminders_interval: re.Match[str] | None = re.fullmatch( r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?\Z", - str( - os.getenv("ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL", "24h") - .strip() - .replace(" ", "") - ), + str(os.getenv("ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL", "24h")), ) raw_timedelta_details_advanced_send_get_roles_reminders_interval: Mapping[ str, float - ] = {"hours": 24} + ] = { + "hours": 24, + } if cls._settings["SEND_GET_ROLES_REMINDERS"]: if not raw_advanced_send_get_roles_reminders_interval: @@ -701,48 +684,36 @@ def _setup_advanced_send_get_roles_reminders_interval(cls) -> None: def _setup_statistics_days(cls) -> None: e: ValueError try: - raw_statistics_days: float = float(os.getenv("STATISTICS_DAYS", "30").strip()) + raw_statistics_days: float = float(os.getenv("STATISTICS_DAYS", "30")) except ValueError as e: INVALID_STATISTICS_DAYS_MESSAGE: Final[str] = ( "STATISTICS_DAYS must contain the statistics period in days." ) raise ImproperlyConfiguredError(INVALID_STATISTICS_DAYS_MESSAGE) from e - if raw_statistics_days < 1: - TOO_SMALL_STATISTICS_DAYS_MESSAGE: Final[str] = ( - "STATISTICS_DAYS cannot be less than or equal to 1 day" - ) - raise ImproperlyConfiguredError(TOO_SMALL_STATISTICS_DAYS_MESSAGE) - cls._settings["STATISTICS_DAYS"] = timedelta(days=raw_statistics_days) @classmethod def _setup_statistics_roles(cls) -> None: - raw_statistics_roles: str = os.getenv("STATISTICS_ROLES", default="").strip() + raw_statistics_roles: str | None = os.getenv("STATISTICS_ROLES") if not raw_statistics_roles: cls._settings["STATISTICS_ROLES"] = DEFAULT_STATISTICS_ROLES - return - cls._settings["STATISTICS_ROLES"] = { - raw_statistics_role.strip() - for raw_statistics_role in raw_statistics_roles.split(",") - if raw_statistics_role - } + else: + cls._settings["STATISTICS_ROLES"] = { + raw_statistics_role + for raw_statistics_role in raw_statistics_roles.split(",") + if raw_statistics_role + } @classmethod def _setup_moderation_document_url(cls) -> None: - raw_moderation_document_url: str = os.getenv( - "MODERATION_DOCUMENT_URL", default="" - ).strip() - - if raw_moderation_document_url and "://" not in raw_moderation_document_url: - raw_moderation_document_url = "https://" + raw_moderation_document_url + raw_moderation_document_url: str | None = os.getenv("MODERATION_DOCUMENT_URL") MODERATION_DOCUMENT_URL_IS_VALID: Final[bool] = bool( - validators.url(raw_moderation_document_url), + raw_moderation_document_url and validators.url(raw_moderation_document_url), ) - if not MODERATION_DOCUMENT_URL_IS_VALID: MODERATION_DOCUMENT_URL_MESSAGE: Final[str] = ( "MODERATION_DOCUMENT_URL must be a valid URL." @@ -755,9 +726,8 @@ def _setup_moderation_document_url(cls) -> None: def _setup_strike_performed_manually_warning_location(cls) -> None: raw_strike_performed_manually_warning_location: str = os.getenv( "MANUAL_MODERATION_WARNING_MESSAGE_LOCATION", - default="DM", - ).strip() - + "DM", + ) if not raw_strike_performed_manually_warning_location: STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION_MESSAGE: Final[str] = ( "MANUAL_MODERATION_WARNING_MESSAGE_LOCATION must be a valid name " @@ -771,9 +741,9 @@ def _setup_strike_performed_manually_warning_location(cls) -> None: @classmethod def _setup_auto_add_committee_to_threads(cls) -> None: - raw_auto_add_committee_to_threads: str = ( - str(os.getenv("AUTO_ADD_COMMITTEE_TO_THREADS", "True")).lower().strip() - ) + raw_auto_add_committee_to_threads: str = str( + os.getenv("AUTO_ADD_COMMITTEE_TO_THREADS", "True") + ).lower() if raw_auto_add_committee_to_threads not in TRUE_VALUES | FALSE_VALUES: INVALID_AUTO_ADD_COMMITTEE_TO_THREADS_MESSAGE: Final[str] = ( From a458f3ba341678393f47f502bcd12425b0968e59 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 31 May 2025 22:12:02 +0000 Subject: [PATCH 4/7] [pre-commit.ci lite] apply automatic fixes --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3b89c9357..83a4c080d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ main = [ "validators>=0.34", ] pre-commit = ["pre-commit>=4.0"] -test = ["gitpython>=3.1.44", "pytest>=8.3", "pytest-cov>=6.1.1"] +test = ["gitpython>=3.1.44", "pytest-cov>=6.1.1", "pytest>=8.3"] type-check = ["django-stubs[compatible-mypy]>=5.1", "mypy>=1.13", "types-beautifulsoup4>=4.12"] [project] # TODO: Remove [project] table once https://github.com/astral-sh/uv/issues/8582 is completed From d1387abcedbe0d0ade3fefa3917e29603cb38147 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Mon, 2 Jun 2025 10:10:59 +0000 Subject: [PATCH 5/7] Start work on utils tests --- tests/test_utils.py | 86 +++++++++++---------------------------------- 1 file changed, 20 insertions(+), 66 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 599ae0f94..71fe58604 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,6 @@ """Test suite for utils package.""" +import asyncio import random import re from typing import TYPE_CHECKING @@ -12,72 +13,6 @@ __all__: "Sequence[str]" = () -# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 -# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 -# class TestPlotBarChart: -# """Test case to unit-test the plot_bar_chart function.""" -# -# def test_bar_chart_generates(self) -> None: -# """Test that the bar chart generates successfully when valid arguments are passed.""" # noqa: ERA001, E501, W505 -# FILENAME: Final[str] = "output_chart.png" # noqa: ERA001 -# DESCRIPTION: Final[str] = "Bar chart of the counted value of different roles." # noqa: ERA001, E501, W505 -# -# bar_chart_image: discord.File = plot_bar_chart( -# data={"role1": 5, "role2": 7}, # noqa: ERA001 -# x_label="Role Name", # noqa: ERA001 -# y_label="Counted value", # noqa: ERA001 -# title="Counted Value Of Each Role", # noqa: ERA001 -# filename=FILENAME, # noqa: ERA001 -# description=DESCRIPTION, # noqa: ERA001 -# extra_text="This is extra text" # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# -# assert bar_chart_image.filename == FILENAME # noqa: ERA001 -# assert bar_chart_image.description == DESCRIPTION # noqa: ERA001 -# assert bool(bar_chart_image.fp.read()) is True # noqa: ERA001 - - -# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 -# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 -# class TestAmountOfTimeFormatter: -# """Test case to unit-test the amount_of_time_formatter function.""" -# -# @pytest.mark.parametrize( -# "time_value", -# (1, 1.0, 0.999999, 1.000001) # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# def test_format_unit_value(self, time_value: float) -> None: -# """Test that a value of one only includes the time_scale.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# formatted_amount_of_time: str = amount_of_time_formatter(time_value, TIME_SCALE) # noqa: ERA001, E501, W505 -# -# assert formatted_amount_of_time == TIME_SCALE # noqa: ERA001 -# assert not formatted_amount_of_time.endswith("s") # noqa: ERA001 -# -# @pytest.mark.parametrize( -# "time_value", -# (*range(2, 21), 2.00, 0, 0.0, 25.0, -0, -0.0, -25.0) # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# def test_format_integer_value(self, time_value: float) -> None: -# """Test that an integer value includes the value and time_scale pluralized.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# assert amount_of_time_formatter( -# time_value, -# TIME_SCALE -# ) == f"{int(time_value)} {TIME_SCALE}s" -# -# @pytest.mark.parametrize("time_value", (3.14159, 0.005, 25.0333333)) -# def test_format_float_value(self, time_value: float) -> None: -# """Test that a float value includes the rounded value and time_scale pluralized.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# assert amount_of_time_formatter( -# time_value, -# TIME_SCALE -# ) == f"{time_value:.2f} {TIME_SCALE}s" - class TestGenerateInviteURL: """Test case to unit-test the generate_invite_url utility function.""" @@ -109,3 +44,22 @@ def test_url_generates() -> None: ), invite_url, ) + + +class TestIsRunningInAsync: + """Test case to unit-test the is_running_in_async utility function.""" + + @staticmethod + def test_is_running_in_async() -> None: + """Test that the is_running_in_async function returns True when called in an async context.""" # noqa: E501, W505 + + async def async_test() -> None: + """Async function to test the is_running_in_async utility.""" + assert utils.is_running_in_async() is True + + asyncio.run(async_test()) + + @staticmethod + def test_is_not_running_in_async() -> None: + """Test that the is_running_in_async function returns False when called in a non-async context.""" # noqa: E501, W505 + assert utils.is_running_in_async() is False From 1a6ff7595f5786cee7ca7acea5d30d500764b0bb Mon Sep 17 00:00:00 2001 From: Holly <25277367+Thatsmusic99@users.noreply.github.com> Date: Sun, 15 Jun 2025 13:49:43 +0100 Subject: [PATCH 6/7] Allow committee-elect to update actions (and appear in auto-complete) (#508) Signed-off-by: Holly <25277367+Thatsmusic99@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> --- cogs/committee_actions_tracking.py | 52 ++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/cogs/committee_actions_tracking.py b/cogs/committee_actions_tracking.py index 35c1fee9e..ccea5bc98 100644 --- a/cogs/committee_actions_tracking.py +++ b/cogs/committee_actions_tracking.py @@ -1,5 +1,6 @@ """Contains cog classes for tracking committee-actions.""" +import contextlib import logging import random from enum import Enum @@ -11,6 +12,7 @@ from db.core.models import AssignedCommitteeAction, DiscordMember from exceptions import ( + CommitteeElectRoleDoesNotExistError, CommitteeRoleDoesNotExistError, InvalidActionDescriptionError, InvalidActionTargetError, @@ -129,11 +131,22 @@ async def autocomplete_get_committee_members( except CommitteeRoleDoesNotExistError: return set() + committee_elect_role: discord.Role | None = None + with contextlib.suppress(CommitteeElectRoleDoesNotExistError): + committee_elect_role = await ctx.bot.committee_elect_role + return { discord.OptionChoice( name=f"{member.display_name} ({member.global_name})", value=str(member.id) ) - for member in committee_role.members + for member in ( + set(committee_role.members) + | ( + set(committee_elect_role.members) + if committee_elect_role is not None + else set() + ) + ) if not member.bot } @@ -281,9 +294,7 @@ async def create( required=True, parameter_name="status", ) - @CommandChecks.check_interaction_user_has_committee_role - @CommandChecks.check_interaction_user_in_main_guild - async def update_status( + async def update_status( # NOTE: Committee role check is not present because non-committee can have actions, and need to be able to list their own actions. self, ctx: "TeXBotApplicationContext", action_id: str, status: str ) -> None: """ @@ -561,9 +572,7 @@ async def action_all_committee( default=None, parameter_name="status", ) - @CommandChecks.check_interaction_user_has_committee_role - @CommandChecks.check_interaction_user_in_main_guild - async def list_user_actions( + async def list_user_actions( # NOTE: Committee role check is not present because non-committee can have actions, and need to be able to list their own actions. self, ctx: "TeXBotApplicationContext", *, @@ -575,15 +584,32 @@ async def list_user_actions( Definition and callback of the "/list" command. Takes in a user and lists out their current actions. + If no user is specified, the user issuing the command will be used. + If a user has the committee role, they can list actions for other users. + If a user does not have the committee role, they can only list their own actions. """ - action_member: discord.Member | discord.User + action_member_id = action_member_id.strip() + + action_member: discord.Member | discord.User = ( + await self.bot.get_member_from_str_id(action_member_id) + if action_member_id + else ctx.user + ) - if action_member_id: - action_member = await self.bot.get_member_from_str_id( - action_member_id, + if action_member != ctx.user and not await self.bot.check_user_has_committee_role( + ctx.user + ): + await ctx.respond( + content="Committee role is required to list actions for other users.", + ephemeral=True, ) - else: - action_member = ctx.user + logger.debug( + "User: %s, tried to list actions for user: %s, " + "but did not have the committee role.", + ctx.user, + action_member, + ) + return user_actions: list[AssignedCommitteeAction] From 6e7318cd8a4cada0c25e63118ad4fd59c645db1b Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sat, 21 Jun 2025 22:43:05 +0000 Subject: [PATCH 7/7] fix --- .github/workflows/check-build-deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-build-deploy.yaml b/.github/workflows/check-build-deploy.yaml index d3e8fbba0..e2a901f64 100644 --- a/.github/workflows/check-build-deploy.yaml +++ b/.github/workflows/check-build-deploy.yaml @@ -44,7 +44,7 @@ jobs: enable-cache: true - name: Install mypy From Locked Dependencies - run: uv sync --no-group dev --group type-check --group test + run: uv sync --no-group dev --group type-check - name: Store Hashed Python Version id: store-hashed-python-version