From 56a3de92c20c458d3b4beb41619a635bde74d4b1 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 17 May 2026 16:06:57 +0100 Subject: [PATCH 01/33] Add stubs and constants --- config/constants.py | 493 +++++++++++++++++++++++++++++++++ stubs/strictyaml/__init__.pyi | 1 + stubs/strictyaml/constants.pyi | 1 + 3 files changed, 495 insertions(+) create mode 100644 config/constants.py create mode 100644 stubs/strictyaml/__init__.pyi create mode 100644 stubs/strictyaml/constants.pyi diff --git a/config/constants.py b/config/constants.py new file mode 100644 index 000000000..6d9bf4bd7 --- /dev/null +++ b/config/constants.py @@ -0,0 +1,493 @@ +"""Constant values that are defined for quick access.""" + +from enum import Enum, EnumMeta +from pathlib import Path +from typing import TYPE_CHECKING, Literal, NamedTuple, override + +from strictyaml import constants as strictyaml_constants + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + from typing import Final, TypeAlias + + +__all__: "Sequence[str]" = ( + "CONFIG_SETTINGS_HELPS", + "DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL", + "DEFAULT_CONSOLE_LOG_LEVEL", + "DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME", + "DEFAULT_DISCORD_LOGGING_LOG_LEVEL", + "DEFAULT_MEMBERS_LIST_ID_FORMAT", + "DEFAULT_MESSAGE_LOCALE_CODE", + "DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY", + "DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY", + "DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED", + "DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL", + "DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY", + "DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED", + "DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL", + "DEFAULT_STATS_COMMAND_DISPLAYED_ROLES", + "DEFAULT_STATS_COMMAND_LOOKBACK_DAYS", + "DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION", + "DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION", + "MESSAGES_LOCALE_CODES", + "PROJECT_ROOT", + "VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES", + "ConfigSettingHelp", + "LogLevels", + "SendIntroductionRemindersFlagType", +) + +SendIntroductionRemindersFlagType: "TypeAlias" = Literal["once", "interval", False] + + +class MetaEnum(EnumMeta): + @override + def __contains__(cls, item: object) -> bool: + try: + cls(item) + except ValueError: + return False + return True + + +class LogLevels(str, Enum, metaclass=MetaEnum): # noqa: UP042 + """Set of valid string values used for logging log-levels.""" + + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class ConfigSettingHelp(NamedTuple): + """Container to hold help information about a single configuration setting.""" + + description: str + value_type_message: str | None + requires_restart_after_changed: bool + required: bool = True + default: str | None = None + + +def _selectable_required_format_message(options: "Iterable[str]") -> str: + return f"Must be one of: `{'`, `'.join(options)}`." + + +def _custom_required_format_message(type_value: str, info_link: str | None = None) -> str: + return f"Must be a valid { + type_value.lower() + .replace('discord', 'Discord') + .replace( + 'id', + 'ID', + ) + .replace('url', 'URL') + .replace('dm', 'DM') + .strip('.') + }{f' (see <{info_link}>)' if info_link else ''}." + + +PROJECT_ROOT: "Final[Path]" = Path(__file__).parent.parent.resolve() + +MESSAGES_LOCALE_CODES: "Final[frozenset[str]]" = frozenset({"en-GB"}) + + +VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES: "Final[frozenset[str]]" = frozenset( + ({"once", "interval"} | set(strictyaml_constants.BOOL_VALUES)), +) + +DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME: "Final[str]" = "TeX-Bot" + + +DEFAULT_CONSOLE_LOG_LEVEL: "Final[LogLevels]" = LogLevels.INFO +DEFAULT_DISCORD_LOGGING_LOG_LEVEL: "Final[LogLevels]" = LogLevels.WARNING +DEFAULT_MEMBERS_LIST_ID_FORMAT: "Final[str]" = r"\A\d{6,7}\Z" +DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY: "Final[float]" = 0.01 +DEFAULT_STATS_COMMAND_LOOKBACK_DAYS: "Final[float]" = 30.0 +DEFAULT_STATS_COMMAND_DISPLAYED_ROLES: "Final[Sequence[str]]" = [ + "Committee", + "Committee-Elect", + "Student Rep", + "Member", + "Guest", + "Server Booster", + "Foundation Year", + "First Year", + "Second Year", + "Final Year", + "Year In Industry", + "Year Abroad", + "PGT", + "PGR", + "Alumnus/Alumna", + "Postdoc", + "Quiz Victor", +] +DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION: "Final[str]" = "24h" +DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION: "Final[str]" = "DM" +DEFAULT_MESSAGE_LOCALE_CODE: "Final[str]" = "en-GB" +DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED: "Final[SendIntroductionRemindersFlagType]" = ( + "once" +) +DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY: "Final[str]" = "40h" +DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL: "Final[str]" = "6h" +DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED: "Final[bool]" = True +DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY: "Final[str]" = "40h" +DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL: "Final[str]" = "6h" +DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL: "Final[str]" = "30s" + +CONFIG_SETTINGS_HELPS: "Mapping[str, ConfigSettingHelp]" = { + "logging:console:log-level": ConfigSettingHelp( + description=( + "The minimum level that logs must meet in order to be logged " + "to the console output stream." + ), + value_type_message=_selectable_required_format_message(LogLevels), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_CONSOLE_LOG_LEVEL, + ), + "logging:discord-channel:log-level": ConfigSettingHelp( + description=( + "The minimum level that logs must meet in order to be logged " + "to the Discord log channel." + ), + value_type_message=_selectable_required_format_message(LogLevels), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_DISCORD_LOGGING_LOG_LEVEL, + ), + "logging:discord-channel:webhook-url": ConfigSettingHelp( + description=( + "The webhook URL of the Discord text channel where error logs should be sent.\n" + "Error logs will always be sent to the console, " + "this setting allows them to also be sent to a Discord log channel." + ), + value_type_message=_custom_required_format_message( + "Discord webhook URL", + "https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks", + ), + requires_restart_after_changed=False, + required=False, + default=None, + ), + "discord:bot-token": ConfigSettingHelp( + description=( + "The Discord token for the bot you created " + "(available on your bot page in the developer portal: )." + ), + value_type_message=_custom_required_format_message( + "Discord bot token", + "https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts", + ), + requires_restart_after_changed=True, + required=True, + default=None, + ), + "discord:main-guild-id": ConfigSettingHelp( + description="The ID of your community group's main Discord guild.", + value_type_message=_custom_required_format_message( + "Discord guild ID", + "https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id", + ), + requires_restart_after_changed=True, + required=True, + default=None, + ), + "community-group:full-name": ConfigSettingHelp( + description=( + "The full name of your community group, do **NOT** use an abbreviation.\n" + "This is substituted into many error/welcome messages " + "sent into your Discord guild, by **`@TeX-Bot`**.\n" + "If this is not set the group-full-name will be retrieved " + "from the name of your group's Discord guild." + ), + requires_restart_after_changed=False, + value_type_message=None, + required=False, + default=None, + ), + "community-group:short-name": ConfigSettingHelp( + description=( + "The short colloquial name of your community group, " + "it is recommended that you set this to be an abbreviation of your group's name.\n" + "If this is not set the group-short-name will be determined " + "from your group's full name." + ), + requires_restart_after_changed=False, + value_type_message=None, + required=False, + default=None, + ), + "community-group:links:purchase-membership": ConfigSettingHelp( + description=( + "The link to the page where guests can purchase a full membership " + "to join your community group." + ), + requires_restart_after_changed=False, + value_type_message=_custom_required_format_message("URL"), + required=False, + default=None, + ), + "community-group:links:membership-perks": ConfigSettingHelp( + description=( + "The link to the page where guests can find out information " + "about the perks that they will receive " + "once they purchase a membership to your community group." + ), + requires_restart_after_changed=False, + value_type_message=_custom_required_format_message("URL"), + required=False, + default=None, + ), + "community-group:links:moderation-document": ConfigSettingHelp( + description="The link to your group's Discord guild moderation document.", + value_type_message=_custom_required_format_message("URL"), + requires_restart_after_changed=False, + required=True, + default=None, + ), + "community-group:members-list:url": ConfigSettingHelp( + description=( + "The URL to retrieve the list of IDs of people that have purchased a membership " + "to your community group.\n" + "Ensure that all members are visible without pagination, " + "(for example, " + "if your members-list is found on the UoB Guild of Students website, " + 'ensure the URL includes the "sort by groups" option).' + ), + requires_restart_after_changed=False, + value_type_message=_custom_required_format_message("URL"), + required=True, + default=None, + ), + "community-group:members-list:auth-session-cookie": ConfigSettingHelp( + description=( + "The members-list authentication session cookie.\n" + "If your group's members-list is stored at a URL that requires authentication, " + "this session cookie should authenticate **`@TeX-Bot`** " + "to view your group's members-list, " + "as if it were logged in to the website as a Committee member.\n" + "If your members-list is found on the UoB Guild of Students website, " + "this can be extracted from your web-browser: " + "after manually logging in to view your members-list, " + "it will probably be listed as a cookie named `.ASPXAUTH`." + ), + requires_restart_after_changed=False, + value_type_message=None, + required=True, + default=None, + ), + "community-group:members-list:id-format": ConfigSettingHelp( + description=( + "The format that IDs are stored in within your members-list.\n" + "Remember to double escape `\\` characters where necessary." + ), + value_type_message=_custom_required_format_message( + "regex matcher string", + ), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_MEMBERS_LIST_ID_FORMAT, + ), + "commands:ping:easter-egg-probability": ConfigSettingHelp( + description=( + "The probability that the more rare ping command response will be sent " + "instead of the normal one." + ), + value_type_message=_custom_required_format_message( + "float, inclusively between 1 & 0", + ), + requires_restart_after_changed=False, + required=False, + default=str(DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY), + ), + "commands:stats:lookback-days": ConfigSettingHelp( + description=( + "The number of days to look over messages sent, to generate statistics data." + ), + value_type_message=_custom_required_format_message( + "float representing the number of days to look back through", + ), + requires_restart_after_changed=False, + required=False, + default=str(DEFAULT_STATS_COMMAND_LOOKBACK_DAYS), + ), + "commands:stats:displayed-roles": ConfigSettingHelp( + description=( + "The names of the roles to gather statistics about, " + "to display in bar chart graphs." + ), + value_type_message=_custom_required_format_message( + "comma seperated list of strings of role names", + ), + requires_restart_after_changed=False, + required=False, + default=",".join(DEFAULT_STATS_COMMAND_DISPLAYED_ROLES), + ), + "commands:strike:timeout-duration": ConfigSettingHelp( + description=( + "The amount of time to timeout a user when using the **`/strike`** command." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds, minutes, hours, days or weeks " + "to timeout a user (format: `smhdw`)" + ), + ), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, + ), + "commands:strike:performed-manually-warning-location": ConfigSettingHelp( + description=( + "The name of the channel, that warning messages will be sent to " + "when a committee-member manually applies a moderation action " + "(instead of using the `/strike` command).\n" + "This can be the name of **ANY** Discord channel " + "(so the offending person *will* be able to see these messages " + "if a public channel is chosen)." + ), + value_type_message=_custom_required_format_message( + ( + "name of a Discord channel in your group's Discord guild, " + "or the value `DM` " + "(which indicates that the messages will be sent " + "in the committee-member's DMs)" + ), + ), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, + ), + "messages-locale-code": ConfigSettingHelp( + description=( + "The locale code used to select the language response messages will be given in." + ), + value_type_message=_selectable_required_format_message( + MESSAGES_LOCALE_CODES, + ), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_MESSAGE_LOCALE_CODE, + ), + "reminders:send-introduction-reminders:enabled": ConfigSettingHelp( + description=( + "Whether introduction reminders will be sent to Discord members " + "that are not inducted, " + "saying that they need to send an introduction to be allowed access." + ), + value_type_message=_selectable_required_format_message( + ( + str(flag_value).lower() + for flag_value in getattr(SendIntroductionRemindersFlagType, "__args__") # noqa: B009 + ), + ), + requires_restart_after_changed=True, + required=False, + default=str(DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED).lower(), + ), + "reminders:send-introduction-reminders:delay": ConfigSettingHelp( + description=( + "How long to wait after a user joins your guild " + "before sending them the first/only message " + "to remind them to send an introduction.\n" + "Is ignored if `reminders:send-introduction-reminders:enabled` **=** `false`.\n" + "The delay must be longer than or equal to 1 day (in any allowed format)." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds, minutes, hours, days or weeks " + "before the first/only reminder is sent " + "(format: `smhdw`)" + ), + ), + requires_restart_after_changed=True, + required=False, + default=DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, + ), + "reminders:send-introduction-reminders:interval": ConfigSettingHelp( + description=( + "The interval of time between sending out reminders " + "to Discord members that are not inducted, " + "saying that they need to send an introduction to be allowed access.\n" + "Is ignored if `reminders:send-introduction-reminders:enabled` **=** `false`." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds, minutes, or hours between reminders " + "(format: `smh`)" + ), + ), + requires_restart_after_changed=True, + required=False, + default=DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, + ), + "reminders:send-get-roles-reminders:enabled": ConfigSettingHelp( + description=( + "Whether reminders will be sent to Discord members that have been inducted, " + "saying that they can get opt-in roles. " + "(This message will be only sent once per Discord member)." + ), + value_type_message=_custom_required_format_message( + "boolean value (either `true` or `false`)", + ), + requires_restart_after_changed=True, + required=False, + default=str(DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED).lower(), + ), + "reminders:send-get-roles-reminders:delay": ConfigSettingHelp( + description=( + "How long to wait after a user is inducted " + "before sending them the message to get some opt-in roles.\n" + "Is ignored if `reminders:send-get-roles-reminders:enabled` **=** `false`.\n" + "The delay must be longer than or equal to 1 day (in any allowed format)." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds, minutes, hours, days or weeks " + "before the first/only reminder is sent " + "(format: `smhdw`)" + ), + ), + requires_restart_after_changed=True, + required=False, + default=DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, + ), + "reminders:send-get-roles-reminders:interval": ConfigSettingHelp( + description=( + "The interval of time between sending out reminders " + "to Discord members that have been inducted, " + "saying that they can get opt-in roles. " + "(This message will be only sent once, " + "the interval is just how often to check for new guests).\n" + "Is ignored if `reminders:send-get-roles-reminders:enabled` **=** `false`." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds, minutes, or hours between reminders " + "(format: `smh`)" + ), + ), + requires_restart_after_changed=True, + required=False, + default=DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, + ), + "check-if-config-changed-interval": ConfigSettingHelp( + description=( + "The interval of time between checking whether the config values, " + "defined in the settings file, have changed." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds or minutes between checks " + "(format: `sm`)" + ), + ), + requires_restart_after_changed=True, + required=False, + default=DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL, + ), +} diff --git a/stubs/strictyaml/__init__.pyi b/stubs/strictyaml/__init__.pyi new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/stubs/strictyaml/__init__.pyi @@ -0,0 +1 @@ + diff --git a/stubs/strictyaml/constants.pyi b/stubs/strictyaml/constants.pyi new file mode 100644 index 000000000..6844963f9 --- /dev/null +++ b/stubs/strictyaml/constants.pyi @@ -0,0 +1 @@ +BOOL_VALUES: list[str] From 7a2606f383025f1bf6500a4d23d5186a60923fc2 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 17 May 2026 17:34:17 +0100 Subject: [PATCH 02/33] building up --- config/__init__.py | 28 ++ config/_settings/__init__.py | 57 +++ config/_settings/_yaml/__init__.py | 80 ++++ .../_yaml/custom_scalar_validators.py | 354 ++++++++++++++++++ stubs/strictyaml/__init__.pyi | 35 ++ stubs/strictyaml/constants.pyi | 2 + stubs/strictyaml/exceptions.pyi | 4 + stubs/strictyaml/utils.pyi | 4 + stubs/strictyaml/yamllocation.pyi | 3 + 9 files changed, 567 insertions(+) create mode 100644 config/__init__.py create mode 100644 config/_settings/__init__.py create mode 100644 config/_settings/_yaml/__init__.py create mode 100644 config/_settings/_yaml/custom_scalar_validators.py create mode 100644 stubs/strictyaml/exceptions.pyi create mode 100644 stubs/strictyaml/utils.pyi create mode 100644 stubs/strictyaml/yamllocation.pyi diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 000000000..3f5f6cacb --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,28 @@ +""" +Contains settings values and import & setup functions. + +Settings values are imported from the .env file or the current environment variables. +These values are used to configure the functionality of the bot at run-time. +""" + +import logging +from typing import TYPE_CHECKING + +from ._settings import SettingsAccessor + +if TYPE_CHECKING: + from collections.abc import Sequence + from logging import Logger + from typing import Final + + +__all__: "Sequence[str]" = ( + "settings", +) + + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + +settings: Final[SettingsAccessor] = SettingsAccessor() + + diff --git a/config/_settings/__init__.py b/config/_settings/__init__.py new file mode 100644 index 000000000..afd225b2f --- /dev/null +++ b/config/_settings/__init__.py @@ -0,0 +1,57 @@ +""" +Contains settings values and setup functions. + +Settings values are imported from the tex-bot-deployment.yaml file. +These values are used to configure the functionality of the bot at run-time. +""" + +import logging +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + from logging import Logger + from typing import Final + + from strictyaml import YAML + + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +class SettingsAccessor: + """ + Settings class that provides access to the settings values. + + Settings values can be accessed via key (like a dictionary) or via class attributes. + """ + + _settings: ClassVar[dict[str, object]] = {} + _most_recent_yaml: ClassVar["YAML | None"] = None + + @classmethod + def _get_invalid_settings_key_message(cls, item: str) -> str: + """Return the message to state that the given settings key is invalid.""" + return f"{item!r} is not a valid settings key." + + @classmethod + async def restore_default(cls, config_setting_name: str) -> None: + """ + Set the specified setting to its default value. + + If the setting does not have a default, it will be removed. + """ + return + + @classmethod + async def set_setting_value(cls, config_setting_name: str, value: object) -> None: + """ + Set the specified setting to the given value. + + If the setting does not exist, it will be created. + """ + return + + + + + diff --git a/config/_settings/_yaml/__init__.py b/config/_settings/_yaml/__init__.py new file mode 100644 index 000000000..b386a0ba9 --- /dev/null +++ b/config/_settings/_yaml/__init__.py @@ -0,0 +1,80 @@ + +from typing import TYPE_CHECKING + +import strictyaml + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from typing import Final + + +__all__: "Sequence[str]" = () + +from config.constants import ( + DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL, + DEFAULT_CONSOLE_LOG_LEVEL, + DEFAULT_DISCORD_LOGGING_LOG_LEVEL, + DEFAULT_MEMBERS_LIST_ID_FORMAT, + DEFAULT_MESSAGE_LOCALE_CODE, + DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, + DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, + DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED, + DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, + DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, + DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED, + DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, + DEFAULT_STATS_COMMAND_DISPLAYED_ROLES, + DEFAULT_STATS_COMMAND_LOOKBACK_DAYS, + DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, + DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, + MESSAGES_LOCALE_CODES, + LogLevels, + SendIntroductionRemindersFlagType, +) + +_DEFAULT_CONSOLE_LOGGING_SETTINGS: "Final[Mapping[str, LogLevels]]" = { + "log-level": DEFAULT_CONSOLE_LOG_LEVEL, +} +_DEFAULT_LOGGING_SETTINGS: "Final[Mapping[str, Mapping[str, LogLevels]]]" = { + "console": _DEFAULT_CONSOLE_LOGGING_SETTINGS, +} +_DEFAULT_PING_COMMAND_SETTINGS: "Final[Mapping[str, float]]" = { + "easter-egg-probability": DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, +} +_DEFAULT_STATS_COMMAND_SETTINGS: "Final[Mapping[str, float | Sequence[str]]]" = { + "lookback-days": DEFAULT_STATS_COMMAND_LOOKBACK_DAYS, + "displayed-roles": DEFAULT_STATS_COMMAND_DISPLAYED_ROLES, +} +_DEFAULT_STRIKE_COMMAND_SETTINGS: "Final[Mapping[str, str]]" = { + "timeout-duration": DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, + "performed-manually-warning-location": DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, +} +_DEFAULT_COMMANDS_SETTINGS: "Final[Mapping[str, Mapping[str, float] | Mapping[str, float | Sequence[str]] | Mapping[str, str]]]" = { # noqa: E501 + "ping": _DEFAULT_PING_COMMAND_SETTINGS, + "stats": _DEFAULT_STATS_COMMAND_SETTINGS, + "strike": _DEFAULT_STRIKE_COMMAND_SETTINGS, +} +_DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS: "Final[Mapping[str, SendIntroductionRemindersFlagType | str]]" = { # noqa: E501 + "enabled": DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED, + "delay": DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, + "interval": DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, +} +_DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS: "Final[Mapping[str, bool | str]]" = { + "enabled": DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED, + "delay": DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, + "interval": DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, +} +_DEFAULT_REMINDERS_SETTINGS: "Final[Mapping[str, Mapping[str, bool | str] | Mapping[str, SendIntroductionRemindersFlagType | str]]]" = { # noqa: E501 + "send-introduction-reminders": _DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS, + "send-get-roles-reminders": _DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS, +} + + +SETTINGS_YAML_SCHEMA: "Final[strictyaml.Validator]" = strictyaml.Map( + +) + + + + + diff --git a/config/_settings/_yaml/custom_scalar_validators.py b/config/_settings/_yaml/custom_scalar_validators.py new file mode 100644 index 000000000..e7a6cb0bd --- /dev/null +++ b/config/_settings/_yaml/custom_scalar_validators.py @@ -0,0 +1,354 @@ +from collections.abc import Sequence + +__all__: Sequence[str] = ( + "BoundedFloatValidator", + "CustomBoolValidator", + "DiscordSnowflakeValidator", + "DiscordWebhookURLValidator", + "LogLevelValidator", + "RegexMatcher", + "SendIntroductionRemindersFlagValidator", + "TimeDeltaValidator", +) + + +import datetime +import functools +import math +import re +from typing import TYPE_CHECKING, override + +import strictyaml +from strictyaml import constants as strictyaml_constants +from strictyaml import utils as strictyaml_utils +from strictyaml.exceptions import YAMLSerializationError + +from config.constants import ( + VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES, + LogLevels, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Final, Literal, NoReturn + + from strictyaml.yamllocation import YAMLChunk + + from config.constants import ( + SendIntroductionRemindersFlagType, + ) + + +class LogLevelValidator(strictyaml.ScalarValidator): + @override + def validate_scalar(self, chunk: "YAMLChunk") -> LogLevels: + val: str = str(chunk.contents).upper().strip(" \n\t-_.") + + if val not in LogLevels: + chunk.expecting_but_found( + "when expecting a valid log-level " f"(one of: '{"', '".join(LogLevels)}')", + ) + raise RuntimeError + + return val # type: ignore[return-value] + + @override + def to_yaml(self, data: object) -> str: + self.should_be_string(data, "expected a valid log-level.") + str_data: str = data.upper().strip(" \n\t-_.") # type: ignore[attr-defined] + + if str_data not in LogLevels: + INVALID_DATA_MESSAGE: Final[str] = ( + f"Got '{data}' when expecting one of: '{"', '".join(LogLevels)}'." + ) + raise YAMLSerializationError(INVALID_DATA_MESSAGE) + + return str_data + + +class DiscordWebhookURLValidator(strictyaml.Url): + @override + def validate_scalar(self, chunk: "YAMLChunk") -> str: + CHUNK_IS_VALID: Final[bool] = bool( + super().__is_absolute_url(chunk.contents) + and chunk.contents.startswith("https://discord.com/api/webhooks/") + ) + if not CHUNK_IS_VALID: + chunk.expecting_but_found("when expecting a Discord webhook URL") + raise RuntimeError + + return chunk.contents + + @override + def to_yaml(self, data: object) -> str: + self.should_be_string(data, "expected a URL,") + + DATA_IS_VALID: Final[bool] = bool( + super().__is_absolute_url(str(data)) + and str(data).startswith("https://discord.com/api/webhooks/") + ) + if not DATA_IS_VALID: + INVALID_DATA_MESSAGE: Final[str] = f"'{data}' is not a Discord webhook URL." + raise YAMLSerializationError(INVALID_DATA_MESSAGE) + + return str(data) + + +class DiscordSnowflakeValidator(strictyaml.Int): + @override + def validate_scalar(self, chunk: "YAMLChunk") -> int: + val: int = super().validate_scalar(chunk) + + if not re.fullmatch(r"\A\d{17,20}\Z", str(val)): + chunk.expecting_but_found("when expecting a Discord snowflake ID") + raise RuntimeError + + return val + + @override + def to_yaml(self, data: object) -> str: + DATA_IS_VALID: Final[bool] = bool( + (strictyaml_utils.is_string(data) or isinstance(data, int)) + and strictyaml_utils.is_integer(str(data)) + and re.fullmatch(r"\A\d{17,20}\Z", str(data)) + ) + if not DATA_IS_VALID: + INVALID_DATA_MESSAGE: Final[str] = f"'{data}' is not a Discord snowflake ID." + raise YAMLSerializationError(INVALID_DATA_MESSAGE) + + return str(data) + + +class RegexMatcher(strictyaml.ScalarValidator): + MATCHING_MESSAGE: str = "when expecting a regular expression matcher" + + @override + def validate_scalar(self, chunk: "YAMLChunk") -> str: + try: + re.compile(chunk.contents) + except re.error: + chunk.expecting_but_found( + self.MATCHING_MESSAGE, + "found arbitrary string", + ) + + return chunk.contents # type: ignore[no-any-return] + + + @override + def to_yaml(self, data: object) -> str: + self.should_be_string(data, self.MATCHING_MESSAGE) + + try: + re.compile(data) # type: ignore[call-overload] + except re.error as regex_error: + INVALID_DATA_MESSAGE: Final[str] = f"{self.MATCHING_MESSAGE} found '{data}'" + raise YAMLSerializationError(INVALID_DATA_MESSAGE) from regex_error + + return data # type: ignore[return-value] + + +class BoundedFloatValidator(strictyaml.Float): + @override + def __init__(self, inclusive_minimum: float, inclusive_maximum: float) -> None: + self.inclusive_minimum: float = inclusive_minimum + self.inclusive_maximum: float = inclusive_maximum + + super().__init__() + + @override + def validate_scalar(self, chunk: "YAMLChunk") -> float: + val: float = super().validate_scalar(chunk) + + if not self.inclusive_minimum <= val <= self.inclusive_maximum: + chunk.expecting_but_found( + ( + "when expecting a float " + f"between {self.inclusive_minimum} & {self.inclusive_maximum}" + ), + ) + raise RuntimeError + + return val + + @override + def to_yaml(self, data: object) -> str: + YAML_SERIALIZATION_ERROR: Final[YAMLSerializationError] = YAMLSerializationError( + ( + f"'{data}' is not a float " + f"between {self.inclusive_minimum} & {self.inclusive_maximum}." + ), + ) + + if strictyaml_utils.is_string(data) and strictyaml_utils.is_decimal(data): + data = float(str(data)) + + if not strictyaml_utils.has_number_type(data): + raise YAML_SERIALIZATION_ERROR + + if not self.inclusive_minimum <= data <= self.inclusive_maximum: # type: ignore[operator] + raise YAML_SERIALIZATION_ERROR + + if math.isnan(data): # type: ignore[arg-type] + return "nan" + if data == float("inf"): + return "inf" + if data == float("-inf"): + return "-inf" + + return str(data) + + +class TimeDeltaValidator(strictyaml.ScalarValidator): + @override + def __init__(self, *, seconds: "Literal[True]" = True, minutes: bool = True, hours: bool = True, days: bool = False, weeks: bool = False) -> None: # noqa: E501 + regex_matcher: str = r"\A" + + time_resolution_name: str + for time_resolution_name in ("seconds", "minutes", "hours", "days", "weeks"): + formatted_time_resolution_name: str = time_resolution_name.lower().strip() + time_resolution: object = locals()[formatted_time_resolution_name] + + if not isinstance(time_resolution, bool): + raise TypeError + + if not time_resolution: + continue + + regex_matcher += ( + r"(?:(?P<" + + formatted_time_resolution_name + + r">(?:\d*\.)?\d+)" + + formatted_time_resolution_name[0] + + ")?" + ) + + regex_matcher += r"\Z" + + self.regex_matcher: re.Pattern[str] = re.compile(regex_matcher) + + def _get_value_from_match(self, match: re.Match[str], key: str) -> float: + if key not in self.regex_matcher.groupindex: + return 0.0 + + value: str | None = match.group(key) + + if not value: + return 0.0 + + try: + return float(value) + except ValueError as float_conversion_error: + raise float_conversion_error from float_conversion_error + + @override + def validate_scalar(self, chunk: "YAMLChunk") -> datetime.timedelta: + chunk_error_func: Callable[[], NoReturn] = functools.partial( + chunk.expecting_but_found, + expecting="when expecting a delay/interval string", + found="found non-matching string", + ) + + match: re.Match[str] | None = self.regex_matcher.fullmatch(chunk.contents) + if match is None: + chunk_error_func() + + try: + return datetime.timedelta( + seconds=self._get_value_from_match(match, "seconds"), + minutes=self._get_value_from_match(match, "minutes"), + hours=self._get_value_from_match(match, "hours"), + days=self._get_value_from_match(match, "days"), + weeks=self._get_value_from_match(match, "weeks"), + ) + except ValueError: + chunk_error_func() + + + @override + def to_yaml(self, data: object) -> str: + if strictyaml_utils.is_string(data): + match: re.Match[str] | None = self.regex_matcher.fullmatch(str(data)) + if match is None: + INVALID_STRING_DATA_MESSAGE: Final[str] = ( + f"when expecting a delay/interval string found {str(data)!r}." + ) + raise YAMLSerializationError(INVALID_STRING_DATA_MESSAGE) + return str(data) + + if not hasattr(data, "total_seconds") or not callable(data.total_seconds): + INVALID_TIMEDELTA_DATA_MESSAGE: Final[str] = ( + f"when expecting a time delta object found {str(data)!r}." + ) + raise YAMLSerializationError(INVALID_TIMEDELTA_DATA_MESSAGE) + + total_seconds: object = data.total_seconds + if not isinstance(total_seconds, float): + raise TypeError + + if (total_seconds / 3600) % 1 == 0: + return f"{int(total_seconds / 3600)}h" + + if total_seconds % 1 == 0: + return f"{int(total_seconds)}s" + + return f"{total_seconds}s" + + +class SendIntroductionRemindersFlagValidator(strictyaml.ScalarValidator): + @override + def validate_scalar(self, chunk: "YAMLChunk") -> "SendIntroductionRemindersFlagType": + val: str = str(chunk.contents).lower() + + if val not in VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES: + chunk.expecting_but_found( + ( + "when expecting a send-introduction-reminders-flag " + "(one of: 'once', 'interval' or 'false')" + ), + ) + raise RuntimeError + + if val in strictyaml_constants.TRUE_VALUES: + return "once" + + if val not in ("once", "interval"): + return False + + return val # type: ignore[return-value] + + + @override + def to_yaml(self, data: object) -> str: + if isinstance(data, bool): + return "once" if data else "false" + + if str(data).lower() not in VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES: + INVALID_DATA_MESSAGE: Final[str] = ( + f"Got '{data}' when expecting one of: 'once', 'interval' or 'false'." + ) + raise YAMLSerializationError(INVALID_DATA_MESSAGE) + + if str(data).lower() in strictyaml_constants.TRUE_VALUES: + return "once" + + if str(data).lower() in strictyaml_constants.FALSE_VALUES: + return "false" + + return str(data).lower() + + +class CustomBoolValidator(strictyaml.Bool): + @override + def to_yaml(self, data: object) -> str: + if isinstance(data, bool): + return "true" if data else "false" + + if str(data).lower() in strictyaml_constants.TRUE_VALUES: + return "true" + + if str(data).lower() in strictyaml_constants.FALSE_VALUES: + return "false" + + INVALID_TYPE_MESSAGE: Final[str] = "Not a boolean" + raise YAMLSerializationError(INVALID_TYPE_MESSAGE) diff --git a/stubs/strictyaml/__init__.pyi b/stubs/strictyaml/__init__.pyi index 8b1378917..529597188 100644 --- a/stubs/strictyaml/__init__.pyi +++ b/stubs/strictyaml/__init__.pyi @@ -1 +1,36 @@ +from typing import override + +from .yamllocation import YAMLChunk + +class YAML: ... + +class Validator: + def should_be_string(self, data: object, message: str) -> None: ... + def to_yaml(self, data: object) -> object: ... + +class MapValidator(Validator): + _validator_dict: dict[str, Validator] + +class ScalarValidator(Validator): + def validate_scalar(self, chunk: YAMLChunk) -> object: ... + +class Map(MapValidator): + def __init__(self, validator_dict: dict[object, object], key_validator: Validator | None = ...) -> None: ... + +class Float(ScalarValidator): + @override + def validate_scalar(self, chunk: YAMLChunk) -> float: ... + +class Int(ScalarValidator): + @override + def validate_scalar(self, chunk: YAMLChunk) -> int: ... + +class Bool(ScalarValidator): + @override + def validate_scalar(self, chunk: YAMLChunk) -> bool: ... + + +class Url(ScalarValidator): + def __is_absolute_url(self, raw: str) -> bool: ... + diff --git a/stubs/strictyaml/constants.pyi b/stubs/strictyaml/constants.pyi index 6844963f9..2cfc3140f 100644 --- a/stubs/strictyaml/constants.pyi +++ b/stubs/strictyaml/constants.pyi @@ -1 +1,3 @@ BOOL_VALUES: list[str] +TRUE_VALUES: list[str] +FALSE_VALUES: list[str] diff --git a/stubs/strictyaml/exceptions.pyi b/stubs/strictyaml/exceptions.pyi new file mode 100644 index 000000000..7800fa9cc --- /dev/null +++ b/stubs/strictyaml/exceptions.pyi @@ -0,0 +1,4 @@ +class YAMLSerializationError(StrictYAMLError): ... +class StrictYAMLError(MarkedYAMLError): ... +class MarkedYAMLError(YAMLError): ... +class YAMLError(Exception): ... diff --git a/stubs/strictyaml/utils.pyi b/stubs/strictyaml/utils.pyi new file mode 100644 index 000000000..cde78fd01 --- /dev/null +++ b/stubs/strictyaml/utils.pyi @@ -0,0 +1,4 @@ +def is_string(value: object) -> bool: ... +def is_integer(value: str) -> bool: ... +def is_decimal(value: object) -> bool: ... +def has_number_type(value: object) -> bool: ... diff --git a/stubs/strictyaml/yamllocation.pyi b/stubs/strictyaml/yamllocation.pyi new file mode 100644 index 000000000..cdc34659d --- /dev/null +++ b/stubs/strictyaml/yamllocation.pyi @@ -0,0 +1,3 @@ +class YAMLChunk: + contents: str + def expecting_but_found(self, expecting: str, found: str = ...) -> None: ... From 0be07722741feda72d7fa3aa39ff336ba8a304df Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 17 May 2026 18:34:12 +0100 Subject: [PATCH 03/33] Progress --- config/__init__.py | 8 +- config/_settings/__init__.py | 13 +-- config/_settings/_yaml/__init__.py | 83 +++++++++++++++---- .../_yaml/custom_scalar_validators.py | 28 ++++--- stubs/strictyaml/__init__.pyi | 14 +++- 5 files changed, 102 insertions(+), 44 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index 3f5f6cacb..e53d40847 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -16,13 +16,9 @@ from typing import Final -__all__: "Sequence[str]" = ( - "settings", -) +__all__: "Sequence[str]" = ("settings",) logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -settings: Final[SettingsAccessor] = SettingsAccessor() - - +settings: "Final[SettingsAccessor]" = SettingsAccessor() diff --git a/config/_settings/__init__.py b/config/_settings/__init__.py index afd225b2f..dfa69ec54 100644 --- a/config/_settings/__init__.py +++ b/config/_settings/__init__.py @@ -6,11 +6,11 @@ """ import logging -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING if TYPE_CHECKING: from logging import Logger - from typing import Final + from typing import ClassVar, Final from strictyaml import YAML @@ -25,8 +25,8 @@ class SettingsAccessor: Settings values can be accessed via key (like a dictionary) or via class attributes. """ - _settings: ClassVar[dict[str, object]] = {} - _most_recent_yaml: ClassVar["YAML | None"] = None + _settings: "ClassVar[dict[str, object]]" = {} + _most_recent_yaml: "ClassVar[YAML | None]" = None @classmethod def _get_invalid_settings_key_message(cls, item: str) -> str: @@ -50,8 +50,3 @@ async def set_setting_value(cls, config_setting_name: str, value: object) -> Non If the setting does not exist, it will be created. """ return - - - - - diff --git a/config/_settings/_yaml/__init__.py b/config/_settings/_yaml/__init__.py index b386a0ba9..6063e6316 100644 --- a/config/_settings/_yaml/__init__.py +++ b/config/_settings/_yaml/__init__.py @@ -1,21 +1,31 @@ - from typing import TYPE_CHECKING import strictyaml +from .custom_scalar_validators import ( + BoundedFloatValidator, + CustomBoolValidator, + DiscordSnowflakeValidator, + DiscordWebhookURLValidator, + LogLevelValidator, + SendIntroductionRemindersFlagValidator, + TimeDeltaValidator, +) + if TYPE_CHECKING: from collections.abc import Mapping, Sequence from typing import Final + from config.constants import ( + LogLevels, + SendIntroductionRemindersFlagType, + ) + __all__: "Sequence[str]" = () from config.constants import ( - DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL, DEFAULT_CONSOLE_LOG_LEVEL, - DEFAULT_DISCORD_LOGGING_LOG_LEVEL, - DEFAULT_MEMBERS_LIST_ID_FORMAT, - DEFAULT_MESSAGE_LOCALE_CODE, DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED, @@ -27,9 +37,6 @@ DEFAULT_STATS_COMMAND_LOOKBACK_DAYS, DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, - MESSAGES_LOCALE_CODES, - LogLevels, - SendIntroductionRemindersFlagType, ) _DEFAULT_CONSOLE_LOGGING_SETTINGS: "Final[Mapping[str, LogLevels]]" = { @@ -70,11 +77,55 @@ } -SETTINGS_YAML_SCHEMA: "Final[strictyaml.Validator]" = strictyaml.Map( - -) - - - - - +SETTINGS_YAML_SCHEMA: "Final[strictyaml.Validator]" = strictyaml.Map({ + strictyaml.Optional("logging", default=_DEFAULT_LOGGING_SETTINGS): strictyaml.Map({ + strictyaml.Optional("console", default=_DEFAULT_CONSOLE_LOGGING_SETTINGS): strictyaml.Map({ + strictyaml.Optional("log-level", default=DEFAULT_CONSOLE_LOG_LEVEL): LogLevelValidator(), + }), + strictyaml.Optional("discord-channel", default=_DEFAULT_CONSOLE_LOGGING_SETTINGS): strictyaml.Map({ + "webhook-url": DiscordWebhookURLValidator(), + strictyaml.Optional("log-level", default=DEFAULT_CONSOLE_LOG_LEVEL): LogLevelValidator(), + }), + }), + "discord": strictyaml.Map({ + "bot-token": strictyaml.Regex( + r"\A(?!.*__.*)(?!.*--.*)(?:([A-Za-z0-9]{24,26})\.([A-Za-z0-9]{6})\.([A-Za-z0-9_-]{27,38}))\Z", + ), + "main-guild-id": DiscordSnowflakeValidator(), + }), + "community-group": strictyaml.Map({ + strictyaml.Optional("full-name"): strictyaml.Regex(r"\A.{1,50}\Z"), + strictyaml.Optional("short-name"): strictyaml.Regex(r"\A(?!.*['&!?:,.#%\"-]['&!?:,.#%\"-].*)(?:[A-Za-z0-9'&!?:,.#%\"-]+)\Z",), + "links": strictyaml.Map({ + strictyaml.Optional("purchase-membership"): strictyaml.Url(), + strictyaml.Optional("membership-perks"): strictyaml.Url(), + strictyaml.Optional("moderation-policy"): strictyaml.Url(), + }), + "msl": strictyaml.Map({ + strictyaml.Optional("organisation-id"): strictyaml.Regex(r"\A\d{4,5}\Z"), + strictyaml.Optional("auth-cookie"): strictyaml.Regex(r"\A[\w-]{512,1024}\Z"), + }), + }), + strictyaml.Optional("commands", default=_DEFAULT_COMMANDS_SETTINGS): strictyaml.Map({ + strictyaml.Optional("ping", default=_DEFAULT_PING_COMMAND_SETTINGS): strictyaml.Map({ + strictyaml.Optional("easter-egg-probability", default=DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY): BoundedFloatValidator(0, 1), + }), + strictyaml.Optional("stats", default=_DEFAULT_STATS_COMMAND_SETTINGS): strictyaml.Map({ + strictyaml.Optional("lookback-days", default=DEFAULT_STATS_COMMAND_LOOKBACK_DAYS): BoundedFloatValidator(5, 1826), + strictyaml.Optional("displayed-roles", default=DEFAULT_STATS_COMMAND_DISPLAYED_ROLES): strictyaml.UniqueSeq(strictyaml.Str()), + }), + strictyaml.Optional("strike", default=_DEFAULT_STRIKE_COMMAND_SETTINGS): strictyaml.Map({ + strictyaml.Optional("performed-manually-warning-location", default=DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION): strictyaml.Str(), + }), + }), + strictyaml.Optional("reminders", default=_DEFAULT_REMINDERS_SETTINGS): strictyaml.Map({ + strictyaml.Optional("send-introduction-reminders", default=_DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS): strictyaml.Map({ + strictyaml.Optional("enabled", default=DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED): SendIntroductionRemindersFlagValidator(), + strictyaml.Optional("delay", default=DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY): TimeDeltaValidator(minutes=True, hours=True, days=True), + strictyaml.Optional("interval", default=DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL): TimeDeltaValidator(minutes=True, hours=True, days=True), + }), + strictyaml.Optional("send-get-roles-reminders", default=_DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS): strictyaml.Map({ + "enabled": CustomBoolValidator(), + }), + }), +}) diff --git a/config/_settings/_yaml/custom_scalar_validators.py b/config/_settings/_yaml/custom_scalar_validators.py index e7a6cb0bd..a18961674 100644 --- a/config/_settings/_yaml/custom_scalar_validators.py +++ b/config/_settings/_yaml/custom_scalar_validators.py @@ -13,7 +13,6 @@ import datetime -import functools import math import re from typing import TYPE_CHECKING, override @@ -29,7 +28,6 @@ ) if TYPE_CHECKING: - from collections.abc import Callable from typing import Final, Literal, NoReturn from strictyaml.yamllocation import YAMLChunk @@ -46,7 +44,7 @@ def validate_scalar(self, chunk: "YAMLChunk") -> LogLevels: if val not in LogLevels: chunk.expecting_but_found( - "when expecting a valid log-level " f"(one of: '{"', '".join(LogLevels)}')", + f"when expecting a valid log-level (one of: '{"', '".join(LogLevels)}')", ) raise RuntimeError @@ -134,7 +132,6 @@ def validate_scalar(self, chunk: "YAMLChunk") -> str: return chunk.contents # type: ignore[no-any-return] - @override def to_yaml(self, data: object) -> str: self.should_be_string(data, self.MATCHING_MESSAGE) @@ -201,7 +198,15 @@ def to_yaml(self, data: object) -> str: class TimeDeltaValidator(strictyaml.ScalarValidator): @override - def __init__(self, *, seconds: "Literal[True]" = True, minutes: bool = True, hours: bool = True, days: bool = False, weeks: bool = False) -> None: # noqa: E501 + def __init__( + self, + *, + seconds: "Literal[True]" = True, + minutes: bool = True, + hours: bool = True, + days: bool = False, + weeks: bool = False, + ) -> None: regex_matcher: str = r"\A" time_resolution_name: str @@ -243,11 +248,12 @@ def _get_value_from_match(self, match: re.Match[str], key: str) -> float: @override def validate_scalar(self, chunk: "YAMLChunk") -> datetime.timedelta: - chunk_error_func: Callable[[], NoReturn] = functools.partial( - chunk.expecting_but_found, - expecting="when expecting a delay/interval string", - found="found non-matching string", - ) + def chunk_error_func() -> "NoReturn": + chunk.expecting_but_found( + expecting="when expecting a delay/interval string", + found="found non-matching string", + ) + raise RuntimeError match: re.Match[str] | None = self.regex_matcher.fullmatch(chunk.contents) if match is None: @@ -264,7 +270,6 @@ def validate_scalar(self, chunk: "YAMLChunk") -> datetime.timedelta: except ValueError: chunk_error_func() - @override def to_yaml(self, data: object) -> str: if strictyaml_utils.is_string(data): @@ -317,7 +322,6 @@ def validate_scalar(self, chunk: "YAMLChunk") -> "SendIntroductionRemindersFlagT return val # type: ignore[return-value] - @override def to_yaml(self, data: object) -> str: if isinstance(data, bool): diff --git a/stubs/strictyaml/__init__.pyi b/stubs/strictyaml/__init__.pyi index 529597188..a22499bfa 100644 --- a/stubs/strictyaml/__init__.pyi +++ b/stubs/strictyaml/__init__.pyi @@ -15,7 +15,9 @@ class ScalarValidator(Validator): def validate_scalar(self, chunk: YAMLChunk) -> object: ... class Map(MapValidator): - def __init__(self, validator_dict: dict[object, object], key_validator: Validator | None = ...) -> None: ... + def __init__( + self, validator_dict: dict[object, object], key_validator: Validator | None = ... + ) -> None: ... class Float(ScalarValidator): @override @@ -29,8 +31,18 @@ class Bool(ScalarValidator): @override def validate_scalar(self, chunk: YAMLChunk) -> bool: ... +class Str(ScalarValidator): ... class Url(ScalarValidator): def __is_absolute_url(self, raw: str) -> bool: ... +class Regex(ScalarValidator): + def __init__(self, regular_expression: str) -> None: ... +class SeqValidator(Validator): ... + +class UniqueSeq(SeqValidator): + def __init__(self, item_validator: Validator) -> None: ... + +class Optional: + def __init__(self, key: str, default: object = None, drop_if_none: bool = True) -> None: ... From e00702ccc619c9ceb1c15a73599f5b85dfa9942d Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Mon, 18 May 2026 11:07:36 +0100 Subject: [PATCH 04/33] ignore line lengths for now 2 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index b0683919e..b5578f3cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -210,6 +210,7 @@ extend-ignore-names = ["BROKEN_*_MESSAGE", "INVALID_*_MESSAGE", "NO_*_MESSAGE"] "stubs/discord/**/*.pyi" = ["F403"] "stubs/discord/commands/__init__.pyi" = ["F405"] "tests/**/test_*.py" = ["S101"] +"config/_settings/_yaml/__init__.py" = ["E501"] [tool.ruff.lint.pycodestyle] ignore-overlong-task-comments = true From 6efdbaee7f2556ebcf0a3f3fd2bcd7afe5718597 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Mon, 18 May 2026 11:27:49 +0100 Subject: [PATCH 05/33] Add more --- config/_settings/_yaml/__init__.py | 24 ++++++++++++++++++++---- config/constants.py | 7 +++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/config/_settings/_yaml/__init__.py b/config/_settings/_yaml/__init__.py index 6063e6316..02cf02e32 100644 --- a/config/_settings/_yaml/__init__.py +++ b/config/_settings/_yaml/__init__.py @@ -26,10 +26,12 @@ from config.constants import ( DEFAULT_CONSOLE_LOG_LEVEL, + DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL, DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED, DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, + DEFAULT_AUTO_ADD_COMMITTEE_TO_THREADS, DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED, DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, @@ -37,6 +39,7 @@ DEFAULT_STATS_COMMAND_LOOKBACK_DAYS, DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, + DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED, ) _DEFAULT_CONSOLE_LOGGING_SETTINGS: "Final[Mapping[str, LogLevels]]" = { @@ -61,6 +64,13 @@ "stats": _DEFAULT_STATS_COMMAND_SETTINGS, "strike": _DEFAULT_STRIKE_COMMAND_SETTINGS, } +_DEFAULT_MSL_AUTO_COOKIE_CHECKING_SETTINGS: "Final[Mapping[str, bool]]" = { + "enabled": DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED, + "interval": DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL, +} +_DEFAULT_MSL_SETTINGS: "Final[Mapping[str, bool | str]]" = { + "auto-cookie-checking": _DEFAULT_MSL_AUTO_COOKIE_CHECKING_SETTINGS, +} _DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS: "Final[Mapping[str, SendIntroductionRemindersFlagType | str]]" = { # noqa: E501 "enabled": DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED, "delay": DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, @@ -88,9 +98,7 @@ }), }), "discord": strictyaml.Map({ - "bot-token": strictyaml.Regex( - r"\A(?!.*__.*)(?!.*--.*)(?:([A-Za-z0-9]{24,26})\.([A-Za-z0-9]{6})\.([A-Za-z0-9_-]{27,38}))\Z", - ), + "bot-token": strictyaml.Regex(r"\A(?!.*__.*)(?!.*--.*)(?:([A-Za-z0-9]{24,26})\.([A-Za-z0-9]{6})\.([A-Za-z0-9_-]{27,38}))\Z"), "main-guild-id": DiscordSnowflakeValidator(), }), "community-group": strictyaml.Map({ @@ -104,6 +112,10 @@ "msl": strictyaml.Map({ strictyaml.Optional("organisation-id"): strictyaml.Regex(r"\A\d{4,5}\Z"), strictyaml.Optional("auth-cookie"): strictyaml.Regex(r"\A[\w-]{512,1024}\Z"), + strictyaml.Optional("auto-cookie-checking", default=_DEFAULT_MSL_AUTO_COOKIE_CHECKING_SETTINGS): strictyaml.Map({ + strictyaml.Optional("enabled", default=DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED): CustomBoolValidator(), + strictyaml.Optional("interval", default=DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL): TimeDeltaValidator(minutes=True, hours=True, days=True), + }), }), }), strictyaml.Optional("commands", default=_DEFAULT_COMMANDS_SETTINGS): strictyaml.Map({ @@ -116,6 +128,7 @@ }), strictyaml.Optional("strike", default=_DEFAULT_STRIKE_COMMAND_SETTINGS): strictyaml.Map({ strictyaml.Optional("performed-manually-warning-location", default=DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION): strictyaml.Str(), + strictyaml.Optional("timeout-duration", default=DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION): TimeDeltaValidator(minutes=True, hours=True, days=True), }), }), strictyaml.Optional("reminders", default=_DEFAULT_REMINDERS_SETTINGS): strictyaml.Map({ @@ -125,7 +138,10 @@ strictyaml.Optional("interval", default=DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL): TimeDeltaValidator(minutes=True, hours=True, days=True), }), strictyaml.Optional("send-get-roles-reminders", default=_DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS): strictyaml.Map({ - "enabled": CustomBoolValidator(), + strictyaml.Optional("enabled", default=DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED): CustomBoolValidator(), + strictyaml.Optional("delay", default=DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY): TimeDeltaValidator(minutes=True, hours=True, days=True), + strictyaml.Optional("interval", default=DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL): TimeDeltaValidator(minutes=True, hours=True, days=True), }), }), + strictyaml.Optional("auto-add-committee-to-threads", default=DEFAULT_AUTO_ADD_COMMITTEE_TO_THREADS): CustomBoolValidator(), }) diff --git a/config/constants.py b/config/constants.py index 6d9bf4bd7..d8e7c84b4 100644 --- a/config/constants.py +++ b/config/constants.py @@ -20,12 +20,14 @@ "DEFAULT_MEMBERS_LIST_ID_FORMAT", "DEFAULT_MESSAGE_LOCALE_CODE", "DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY", + "DEFAULT_AUTO_ADD_COMMITTEE_TO_THREADS", "DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY", "DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED", "DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL", "DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY", "DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED", "DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL", + "DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED", "DEFAULT_STATS_COMMAND_DISPLAYED_ROLES", "DEFAULT_STATS_COMMAND_LOOKBACK_DAYS", "DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION", @@ -138,6 +140,11 @@ def _custom_required_format_message(type_value: str, info_link: str | None = Non DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL: "Final[str]" = "6h" DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL: "Final[str]" = "30s" +DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED: "Final[bool]" = False +DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL: "Final[str]" = "10m" + +DEFAULT_AUTO_ADD_COMMITTEE_TO_THREADS: "Final[bool]" = True + CONFIG_SETTINGS_HELPS: "Mapping[str, ConfigSettingHelp]" = { "logging:console:log-level": ConfigSettingHelp( description=( From 1ae59d86d84944876e0bbe0f74addee3959f3191 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 19 May 2026 09:47:51 +0100 Subject: [PATCH 06/33] Add more config --- config/_settings/_yaml/__init__.py | 25 +++++++++++++++++++------ config/constants.py | 6 ++++++ pyproject.toml | 3 +++ uv.lock | 16 ++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/config/_settings/_yaml/__init__.py b/config/_settings/_yaml/__init__.py index 02cf02e32..0e8580aa3 100644 --- a/config/_settings/_yaml/__init__.py +++ b/config/_settings/_yaml/__init__.py @@ -28,6 +28,9 @@ DEFAULT_CONSOLE_LOG_LEVEL, DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL, DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, + DEFAULT_DISCORD_API_LOGGING_ENABLED, + DEFAULT_DISCORD_API_LOGGING_FILE_NAME, + DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL, DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED, DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, @@ -45,8 +48,14 @@ _DEFAULT_CONSOLE_LOGGING_SETTINGS: "Final[Mapping[str, LogLevels]]" = { "log-level": DEFAULT_CONSOLE_LOG_LEVEL, } +_DEFAULT_DISCORD_API_LOGGING_SETTINGS: "Final[Mapping[str, bool | str]]" = { + "enabled": DEFAULT_DISCORD_API_LOGGING_ENABLED, + "log-level": DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL, + "file-name": DEFAULT_DISCORD_API_LOGGING_FILE_NAME, +} _DEFAULT_LOGGING_SETTINGS: "Final[Mapping[str, Mapping[str, LogLevels]]]" = { "console": _DEFAULT_CONSOLE_LOGGING_SETTINGS, + "discord-api": _DEFAULT_DISCORD_API_LOGGING_SETTINGS, } _DEFAULT_PING_COMMAND_SETTINGS: "Final[Mapping[str, float]]" = { "easter-egg-probability": DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, @@ -59,7 +68,7 @@ "timeout-duration": DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, "performed-manually-warning-location": DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, } -_DEFAULT_COMMANDS_SETTINGS: "Final[Mapping[str, Mapping[str, float] | Mapping[str, float | Sequence[str]] | Mapping[str, str]]]" = { # noqa: E501 +_DEFAULT_COMMANDS_SETTINGS: "Final[Mapping[str, Mapping[str, float] | Mapping[str, float | Sequence[str]] | Mapping[str, str]]]" = { "ping": _DEFAULT_PING_COMMAND_SETTINGS, "stats": _DEFAULT_STATS_COMMAND_SETTINGS, "strike": _DEFAULT_STRIKE_COMMAND_SETTINGS, @@ -68,10 +77,7 @@ "enabled": DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED, "interval": DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL, } -_DEFAULT_MSL_SETTINGS: "Final[Mapping[str, bool | str]]" = { - "auto-cookie-checking": _DEFAULT_MSL_AUTO_COOKIE_CHECKING_SETTINGS, -} -_DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS: "Final[Mapping[str, SendIntroductionRemindersFlagType | str]]" = { # noqa: E501 +_DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS: "Final[Mapping[str, SendIntroductionRemindersFlagType | str]]" = { "enabled": DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED, "delay": DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, "interval": DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, @@ -81,7 +87,7 @@ "delay": DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, "interval": DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, } -_DEFAULT_REMINDERS_SETTINGS: "Final[Mapping[str, Mapping[str, bool | str] | Mapping[str, SendIntroductionRemindersFlagType | str]]]" = { # noqa: E501 +_DEFAULT_REMINDERS_SETTINGS: "Final[Mapping[str, Mapping[str, bool | str] | Mapping[str, SendIntroductionRemindersFlagType | str]]]" = { "send-introduction-reminders": _DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS, "send-get-roles-reminders": _DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS, } @@ -96,6 +102,11 @@ "webhook-url": DiscordWebhookURLValidator(), strictyaml.Optional("log-level", default=DEFAULT_CONSOLE_LOG_LEVEL): LogLevelValidator(), }), + strictyaml.Optional("discord-api", default=_DEFAULT_DISCORD_API_LOGGING_SETTINGS): strictyaml.Map({ + strictyaml.Optional("enabled", default=DEFAULT_DISCORD_API_LOGGING_ENABLED): CustomBoolValidator(), + strictyaml.Optional("log-level", default=DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL): LogLevelValidator(), + strictyaml.Optional("file-name", default=DEFAULT_DISCORD_API_LOGGING_FILE_NAME): strictyaml.Str(), + }), }), "discord": strictyaml.Map({ "bot-token": strictyaml.Regex(r"\A(?!.*__.*)(?!.*--.*)(?:([A-Za-z0-9]{24,26})\.([A-Za-z0-9]{6})\.([A-Za-z0-9_-]{27,38}))\Z"), @@ -104,10 +115,12 @@ "community-group": strictyaml.Map({ strictyaml.Optional("full-name"): strictyaml.Regex(r"\A.{1,50}\Z"), strictyaml.Optional("short-name"): strictyaml.Regex(r"\A(?!.*['&!?:,.#%\"-]['&!?:,.#%\"-].*)(?:[A-Za-z0-9'&!?:,.#%\"-]+)\Z",), + strictyaml.Optional("membership-dependent-roles"): strictyaml.Str(), "links": strictyaml.Map({ strictyaml.Optional("purchase-membership"): strictyaml.Url(), strictyaml.Optional("membership-perks"): strictyaml.Url(), strictyaml.Optional("moderation-policy"): strictyaml.Url(), + strictyaml.Optional("custom-discord-invite-link"): strictyaml.Url(), }), "msl": strictyaml.Map({ strictyaml.Optional("organisation-id"): strictyaml.Regex(r"\A\d{4,5}\Z"), diff --git a/config/constants.py b/config/constants.py index d8e7c84b4..496d960a0 100644 --- a/config/constants.py +++ b/config/constants.py @@ -16,6 +16,9 @@ "DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL", "DEFAULT_CONSOLE_LOG_LEVEL", "DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME", + "DEFAULT_DISCORD_API_LOGGING_ENABLED", + "DEFAULT_DISCORD_API_LOGGING_FILE_NAME", + "DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL", "DEFAULT_DISCORD_LOGGING_LOG_LEVEL", "DEFAULT_MEMBERS_LIST_ID_FORMAT", "DEFAULT_MESSAGE_LOCALE_CODE", @@ -102,6 +105,9 @@ def _custom_required_format_message(type_value: str, info_link: str | None = Non DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME: "Final[str]" = "TeX-Bot" +DEFAULT_DISCORD_API_LOGGING_ENABLED: "Final[bool]" = False +DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL: "Final[LogLevels]" = LogLevels.INFO +DEFAULT_DISCORD_API_LOGGING_FILE_NAME: "Final[str]" = "discord.log" DEFAULT_CONSOLE_LOG_LEVEL: "Final[LogLevels]" = LogLevels.INFO DEFAULT_DISCORD_LOGGING_LOG_LEVEL: "Final[LogLevels]" = LogLevels.WARNING diff --git a/pyproject.toml b/pyproject.toml index b5578f3cd..9101f8060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,9 @@ name = "TeX-Bot-Py-V2" version = "0.1.0" requires-python = "==3.13.*" # TODO: Make minimum version Python 3.14, once Pycord makes a new release with support for it +dependencies = [ + "strictyaml>=1.7.3", +] [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index e720ba094..0d87a03e6 100644 --- a/uv.lock +++ b/uv.lock @@ -1046,10 +1046,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] +[[package]] +name = "strictyaml" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/08/efd28d49162ce89c2ad61a88bd80e11fb77bc9f6c145402589112d38f8af/strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407", size = 115206, upload-time = "2023-03-10T12:50:27.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size = 123917, upload-time = "2023-03-10T12:50:17.242Z" }, +] + [[package]] name = "tex-bot-py-v2" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "strictyaml" }, +] [package.dev-dependencies] dev = [ @@ -1096,6 +1111,7 @@ type-check = [ ] [package.metadata] +requires-dist = [{ name = "strictyaml", specifier = ">=1.7.3" }] [package.metadata.requires-dev] dev = [ From 7f1529f3078cc97b6a2563d8689dd1c24ac5e0a4 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 20 May 2026 09:23:10 +0100 Subject: [PATCH 07/33] Fixes --- config/_settings/_yaml/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/_settings/_yaml/__init__.py b/config/_settings/_yaml/__init__.py index 0e8580aa3..e4693022f 100644 --- a/config/_settings/_yaml/__init__.py +++ b/config/_settings/_yaml/__init__.py @@ -73,7 +73,7 @@ "stats": _DEFAULT_STATS_COMMAND_SETTINGS, "strike": _DEFAULT_STRIKE_COMMAND_SETTINGS, } -_DEFAULT_MSL_AUTO_COOKIE_CHECKING_SETTINGS: "Final[Mapping[str, bool]]" = { +_DEFAULT_MSL_AUTO_COOKIE_CHECKING_SETTINGS: "Final[Mapping[str, bool | str]]" = { "enabled": DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED, "interval": DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL, } @@ -115,7 +115,7 @@ "community-group": strictyaml.Map({ strictyaml.Optional("full-name"): strictyaml.Regex(r"\A.{1,50}\Z"), strictyaml.Optional("short-name"): strictyaml.Regex(r"\A(?!.*['&!?:,.#%\"-]['&!?:,.#%\"-].*)(?:[A-Za-z0-9'&!?:,.#%\"-]+)\Z",), - strictyaml.Optional("membership-dependent-roles"): strictyaml.Str(), + strictyaml.Optional("membership-dependent-roles"): strictyaml.UniqueSeq(strictyaml.Str()), "links": strictyaml.Map({ strictyaml.Optional("purchase-membership"): strictyaml.Url(), strictyaml.Optional("membership-perks"): strictyaml.Url(), From 2aa36cfdba7deb41d1c10865c16893f77d730255 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 21 May 2026 08:21:02 +0100 Subject: [PATCH 08/33] Add more --- config/_settings/_yaml/__init__.py | 1 + config/constants.py | 1 + 2 files changed, 2 insertions(+) diff --git a/config/_settings/_yaml/__init__.py b/config/_settings/_yaml/__init__.py index e4693022f..070a50f6a 100644 --- a/config/_settings/_yaml/__init__.py +++ b/config/_settings/_yaml/__init__.py @@ -142,6 +142,7 @@ strictyaml.Optional("strike", default=_DEFAULT_STRIKE_COMMAND_SETTINGS): strictyaml.Map({ strictyaml.Optional("performed-manually-warning-location", default=DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION): strictyaml.Str(), strictyaml.Optional("timeout-duration", default=DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION): TimeDeltaValidator(minutes=True, hours=True, days=True), + strictyaml.Optional("reported-message-destination-channel", default=DEFAULT_STRIKE_REPORTED_MESSAGE_DESTINATION_CHANNEL): strictyaml.Str(), }), }), strictyaml.Optional("reminders", default=_DEFAULT_REMINDERS_SETTINGS): strictyaml.Map({ diff --git a/config/constants.py b/config/constants.py index 496d960a0..b19e9e836 100644 --- a/config/constants.py +++ b/config/constants.py @@ -133,6 +133,7 @@ def _custom_required_format_message(type_value: str, info_link: str | None = Non "Postdoc", "Quiz Victor", ] +DEFAULT_STRIKE_REPORTED_MESSAGE_DESTINATION_CHANNEL: "Final[str]" = "discord" DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION: "Final[str]" = "24h" DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION: "Final[str]" = "DM" DEFAULT_MESSAGE_LOCALE_CODE: "Final[str]" = "en-GB" From f814bf4ef93192c8cf77249ee46d958410614743 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 21 May 2026 18:14:32 +0100 Subject: [PATCH 09/33] Fixes --- config/_settings/_yaml/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/config/_settings/_yaml/__init__.py b/config/_settings/_yaml/__init__.py index 070a50f6a..61fd476cb 100644 --- a/config/_settings/_yaml/__init__.py +++ b/config/_settings/_yaml/__init__.py @@ -25,16 +25,17 @@ __all__: "Sequence[str]" = () from config.constants import ( + DEFAULT_AUTO_ADD_COMMITTEE_TO_THREADS, DEFAULT_CONSOLE_LOG_LEVEL, - DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL, - DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, DEFAULT_DISCORD_API_LOGGING_ENABLED, DEFAULT_DISCORD_API_LOGGING_FILE_NAME, DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL, + DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED, + DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL, + DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED, DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, - DEFAULT_AUTO_ADD_COMMITTEE_TO_THREADS, DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED, DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, @@ -42,7 +43,7 @@ DEFAULT_STATS_COMMAND_LOOKBACK_DAYS, DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, - DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED, + DEFAULT_STRIKE_REPORTED_MESSAGE_DESTINATION_CHANNEL, ) _DEFAULT_CONSOLE_LOGGING_SETTINGS: "Final[Mapping[str, LogLevels]]" = { @@ -53,7 +54,7 @@ "log-level": DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL, "file-name": DEFAULT_DISCORD_API_LOGGING_FILE_NAME, } -_DEFAULT_LOGGING_SETTINGS: "Final[Mapping[str, Mapping[str, LogLevels]]]" = { +_DEFAULT_LOGGING_SETTINGS: "Final[Mapping[str, Mapping[str, LogLevels | bool | str]]]" = { "console": _DEFAULT_CONSOLE_LOGGING_SETTINGS, "discord-api": _DEFAULT_DISCORD_API_LOGGING_SETTINGS, } From 0458b09ac7fa116a4f08a1ace121d01d88388f0d Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 21 May 2026 20:28:46 +0100 Subject: [PATCH 10/33] Updates --- exceptions/__init__.py | 6 +++- exceptions/config_changes.py | 62 ++++++++++++++++++++++++----------- pyproject.toml | 6 ++-- stubs/strictyaml/__init__.pyi | 1 + uv.lock | 62 ++++++++++++++++++++++++++++++++--- 5 files changed, 109 insertions(+), 28 deletions(-) diff --git a/exceptions/__init__.py b/exceptions/__init__.py index dc3c44ced..1ff13bdfa 100644 --- a/exceptions/__init__.py +++ b/exceptions/__init__.py @@ -3,7 +3,10 @@ from typing import TYPE_CHECKING from .committee_actions import InvalidActionDescriptionError, InvalidActionTargetError -from .config_changes import ImproperlyConfiguredError, RestartRequiredDueToConfigChange +from .config_changes import ( + ChangingSettingWithRequiredSiblingError, + RestartRequiredDueToConfigChange, +) from .does_not_exist import ( ApplicantRoleDoesNotExistError, ArchivistRoleDoesNotExistError, @@ -33,6 +36,7 @@ __all__: "Sequence[str]" = ( "ApplicantRoleDoesNotExistError", "ArchivistRoleDoesNotExistError", + "ChangingSettingWithRequiredSiblingError", "ChannelDoesNotExistError", "CommitteeElectRoleDoesNotExistError", "CommitteeRoleDoesNotExistError", diff --git a/exceptions/config_changes.py b/exceptions/config_changes.py index 9ea28c41a..97abe11be 100644 --- a/exceptions/config_changes.py +++ b/exceptions/config_changes.py @@ -1,28 +1,22 @@ """Custom exception classes related to configuration changes.""" -from typing import TYPE_CHECKING, override +from collections.abc import Sequence -from typed_classproperties import classproperty - -from .base import BaseTeXBotError +__all__: Sequence[str] = ( + "ChangingSettingWithRequiredSiblingError", + "RestartRequiredDueToConfigChange", +) -if TYPE_CHECKING: - from collections.abc import Sequence - from collections.abc import Set as AbstractSet -__all__: "Sequence[str]" = ("ImproperlyConfiguredError", "RestartRequiredDueToConfigChange") +from collections.abc import Set +from typing import override +from typed_classproperties import classproperty -class ImproperlyConfiguredError(BaseTeXBotError, Exception): - """Exception class to raise when environment variables are not correctly provided.""" - - @classproperty - @override - def DEFAULT_MESSAGE(cls) -> str: - return "One or more provided environment variable values are invalid." +from .base import BaseTeXBotError -class RestartRequiredDueToConfigChange(BaseTeXBotError, Exception): # noqa: N818 +class RestartRequiredDueToConfigChange(BaseTeXBotError, Exception): """Exception class to raise when a restart is required to apply config changes.""" @classproperty @@ -31,10 +25,38 @@ def DEFAULT_MESSAGE(cls) -> str: return "TeX-Bot requires a restart to apply configuration changes." @override - def __init__( - self, message: str | None = None, changed_settings: "AbstractSet[str] | None" = None - ) -> None: + def __init__(self, message: str | None = None, changed_settings: Set[str] | None = None) -> None: # noqa: E501 """Initialise an Exception to apply configuration changes.""" - self.changed_settings: AbstractSet[str] | None = changed_settings or set() + self.changed_settings: Set[str] | None = ( + changed_settings if changed_settings else set() + ) super().__init__(message) + + +class ChangingSettingWithRequiredSiblingError(BaseTeXBotError, ValueError): + """Exception class for when a setting cannot be changed because of required siblings.""" + + # noinspection PyMethodParameters,PyPep8Naming + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + """The message to be displayed alongside this exception class if none is provided.""" + return ( + "The given setting cannot be changed " + "because it has one or more required sibling settings that must be set first." + ) + + @override + def __init__(self, message: str | None = None, config_setting_name: str | None = None) -> None: # noqa: E501 + self.config_setting_name: str | None = config_setting_name + + super().__init__( + message + or ( + f"Cannot assign value to config setting '{config_setting_name}' " + f"because it has one or more required sibling settings that must be set first." + if config_setting_name + else message + ) + ) diff --git a/pyproject.toml b/pyproject.toml index 9101f8060..ea00921ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,6 @@ name = "TeX-Bot-Py-V2" version = "0.1.0" requires-python = "==3.13.*" # TODO: Make minimum version Python 3.14, once Pycord makes a new release with support for it -dependencies = [ - "strictyaml>=1.7.3", -] [dependency-groups] dev = [ @@ -15,6 +12,8 @@ dev = [ ] lint-format = ["pymarkdownlnt>=0.9.28", "ruff>=0.12"] main = [ + "aiopath>=0.7.7", + "anyio>=4.13.0", "asyncstdlib>=3.13", "audioop-lts; python_version > '3.12'", "beautifulsoup4>=4.12", @@ -27,6 +26,7 @@ main = [ "py-cord>=2.6,<2.7", "python-dotenv>=1.0", "python-logging-discord-handler>=0.1", + "strictyaml>=1.7.3", "typed_classproperties>=1.2", "validators>=0.34" ] diff --git a/stubs/strictyaml/__init__.pyi b/stubs/strictyaml/__init__.pyi index a22499bfa..cb9f230cf 100644 --- a/stubs/strictyaml/__init__.pyi +++ b/stubs/strictyaml/__init__.pyi @@ -1,5 +1,6 @@ from typing import override +from .exceptions import StrictYAMLError from .yamllocation import YAMLChunk class YAML: ... diff --git a/uv.lock b/uv.lock index 0d87a03e6..253e323c8 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,18 @@ version = 1 revision = 3 requires-python = "==3.13.*" +[[package]] +name = "aiofile" +version = "3.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/31/edb06aabd8f8f0b56d659f30800795f40b93cba96be946ce179f6931e3a5/aiofile-3.12.3.tar.gz", hash = "sha256:caa6aa746b5e47e2165f7abd741b6415e49cf4d44fddc0f61844612cc3924d41", size = 21600, upload-time = "2026-08-04T22:59:27.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.7.1" @@ -51,6 +63,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, ] +[[package]] +name = "aiopath" +version = "0.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofile" }, + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/6f/b377e3eb293feb57e457b33961004cc3716794ef97428d2ae2a326599b1c/aiopath-0.7.7.tar.gz", hash = "sha256:ad4b9d09ae08ddf6d39dd06e7b0a353939e89528da571c0cd4f3fe071aefad4f", size = 16925, upload-time = "2023-10-16T22:37:11.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/df/6a3826363e4848ffa8972410ab5234d3802597ae92f4a6397600149112c7/aiopath-0.7.7-py2.py3-none-any.whl", hash = "sha256:cd5d18de8ede167e1db659f02ee448fe085f923cb8e194407ccc568bffc4fe4e", size = 12200, upload-time = "2023-10-16T22:37:09.595Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -63,6 +88,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "application-file-scanner" version = "0.6.4" @@ -196,6 +233,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] +[[package]] +name = "caio" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/c8/82b3c760141a1076408164b03e8789b51809add6aecd48aa9d7651cf6b59/caio-0.12.2.tar.gz", hash = "sha256:87a67c0dccc60e432888bd532ec504b66e124a5d8b391aab894583b55abd39ea", size = 80927, upload-time = "2026-08-04T14:43:33.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/9b/31f0b49a2542ffa2f9d6140267e2b568e722a1feeb05cfbffea97666c62b/caio-0.12.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:40ebea9ebe3a3a66ae85fa00d4112d163654a33c82dcf9b26a99f7d30de13317", size = 84656, upload-time = "2026-08-04T14:43:10.513Z" }, + { url = "https://files.pythonhosted.org/packages/99/bc/62568d688af9712a34fe3f958d7a98c53bb2017e263260cd5deae67a90e9/caio-0.12.2-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:6003ec389a68d5ec8f089df82b2dc8915293dd630a4d11322d7e3455045981fd", size = 198443, upload-time = "2026-08-04T14:43:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e4/5ed627860285612e5307f06c109913c5918c947fbc223b55599e484c64b0/caio-0.12.2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:eee9376d0e2af25b6defc5bce39f6efa90521c803aaf12eba931bd898a397cfc", size = 196356, upload-time = "2026-08-04T14:43:13.206Z" }, + { url = "https://files.pythonhosted.org/packages/81/e2/2a8cfc6ba3ef3f19e7c778e9fb6f98600f0971cca78bbdfc23a413a66349/caio-0.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:78e3ccafc98e009fcb00a97ad441585551e52c0ae7ecc50427a3ccd9b11502fd", size = 195893, upload-time = "2026-08-04T14:43:14.649Z" }, + { url = "https://files.pythonhosted.org/packages/d1/87/77c40fb2301d0b5bb27c2e79ae42fce718ed75396d5fe3e1c09d8e1400b1/caio-0.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f2355db8917f5a0f3638bf332fe0d87549c80e978fca01db84a8a14b9df56a05", size = 195969, upload-time = "2026-08-04T14:43:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b5/0ceca97eb546fe6bbace3399c8b11dfc503efcc7509d708a7a3f09ab50e9/caio-0.12.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8054cba5e7ee623bea34946e2b59eb7c7c2be8872d0a5d12215d6ff564938d5f", size = 78621, upload-time = "2026-08-04T14:43:17.316Z" }, + { url = "https://files.pythonhosted.org/packages/61/8a/71b0144f783468ba9f1bbf8a2f8e45c7d85ae31ec192f10650aa46f31702/caio-0.12.2-py3-none-any.whl", hash = "sha256:5233e797c9fe2b541914b1bc2e2df82677e2206b537e44e252188f3c2cbb0ea9", size = 62548, upload-time = "2026-08-04T14:43:32.394Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -1062,9 +1114,6 @@ wheels = [ name = "tex-bot-py-v2" version = "0.1.0" source = { virtual = "." } -dependencies = [ - { name = "strictyaml" }, -] [package.dev-dependencies] dev = [ @@ -1082,6 +1131,8 @@ lint-format = [ { name = "ruff" }, ] main = [ + { name = "aiopath" }, + { name = "anyio" }, { name = "asyncstdlib" }, { name = "audioop-lts" }, { name = "beautifulsoup4" }, @@ -1094,6 +1145,7 @@ main = [ { name = "py-cord" }, { name = "python-dotenv" }, { name = "python-logging-discord-handler" }, + { name = "strictyaml" }, { name = "typed-classproperties" }, { name = "validators" }, ] @@ -1111,7 +1163,6 @@ type-check = [ ] [package.metadata] -requires-dist = [{ name = "strictyaml", specifier = ">=1.7.3" }] [package.metadata.requires-dev] dev = [ @@ -1129,6 +1180,8 @@ lint-format = [ { name = "ruff", specifier = ">=0.12" }, ] main = [ + { name = "aiopath", specifier = ">=0.7.7" }, + { name = "anyio", specifier = ">=4.13.0" }, { name = "asyncstdlib", specifier = ">=3.13" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, { name = "beautifulsoup4", specifier = ">=4.12" }, @@ -1141,6 +1194,7 @@ main = [ { name = "py-cord", specifier = ">=2.6,<2.7" }, { name = "python-dotenv", specifier = ">=1.0" }, { name = "python-logging-discord-handler", specifier = ">=0.1" }, + { name = "strictyaml", specifier = ">=1.7.3" }, { name = "typed-classproperties", specifier = ">=1.2" }, { name = "validators", specifier = ">=0.34" }, ] From b06e29d2f82130184c7d01cb64371d82d23ad5b8 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 21 May 2026 20:49:05 +0100 Subject: [PATCH 11/33] Stuff --- config/_settings/__init__.py | 67 ++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/config/_settings/__init__.py b/config/_settings/__init__.py index dfa69ec54..e40b8e1cd 100644 --- a/config/_settings/__init__.py +++ b/config/_settings/__init__.py @@ -5,10 +5,15 @@ These values are used to configure the functionality of the bot at run-time. """ +import datetime import logging +import re from typing import TYPE_CHECKING +import utils + if TYPE_CHECKING: + from collections.abc import Sequence from logging import Logger from typing import ClassVar, Final @@ -42,11 +47,59 @@ async def restore_default(cls, config_setting_name: str) -> None: """ return - @classmethod - async def set_setting_value(cls, config_setting_name: str, value: object) -> None: - """ - Set the specified setting to the given value. + def __getattr__(self, item: str) -> object: + """Retrieve settings value by attribute lookup.""" + MISSING_ATTRIBUTE_MESSAGE: Final[str] = ( + f"{type(self).__name__!r} object has no attribute {item!r}" + ) + + if "_pytest" in item or item in ("__bases__", "__test__"): # NOTE: Overriding __getattr__() leads to many edge-case issues where external libraries will attempt to call getattr() with peculiar values + raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) + + IN_SETTING_KEY_FORMAT: Final[bool] = bool( + re.fullmatch(r"\A(?!.*__.*)(?:[A-Z]|[A-Z_][A-Z]|[A-Z_][A-Z][A-Z_]*[A-Z])\Z", item) + ) + if not IN_SETTING_KEY_FORMAT: + raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) + + if self._most_recent_yaml is None: + YAML_NOT_LOADED_MESSAGE: Final[str] = ( + "Configuration cannot be accessed before it is loaded." + ) + raise RuntimeError(YAML_NOT_LOADED_MESSAGE) + + if item not in self._settings: + INVALID_SETTINGS_KEY_MESSAGE: Final[str] = self._get_invalid_settings_key_message( + item, + ) + raise AttributeError(INVALID_SETTINGS_KEY_MESSAGE) + + ATTEMPTING_TO_ACCESS_BOT_TOKEN_WHEN_ALREADY_RUNNING: Final[bool] = bool( + "bot" in item.lower() and "token" in item.lower() and utils.is_running_in_async() + ) + if ATTEMPTING_TO_ACCESS_BOT_TOKEN_WHEN_ALREADY_RUNNING: + TEX_BOT_ALREADY_RUNNING_MESSAGE: Final[str] = ( + f"Cannot access {item!r} when TeX-Bot is already running." + ) + raise RuntimeError(TEX_BOT_ALREADY_RUNNING_MESSAGE) + + return self._settings[item] + + def __getitem__(self, item: str) -> object: + """Retrieve settings value by key lookup.""" + attribute_not_exist_error: AttributeError + try: + return getattr(self, item) + except AttributeError as attribute_not_exist_error: + key_error_message: str = item + + ERROR_WAS_FROM_INVALID_KEY_NAME: Final[bool] = ( + self._get_invalid_settings_key_message(item) in str( + attribute_not_exist_error, + ) + ) + if ERROR_WAS_FROM_INVALID_KEY_NAME: + key_error_message = str(attribute_not_exist_error) + + raise KeyError(key_error_message) from None - If the setting does not exist, it will be created. - """ - return From 063ddbb36273c4b6871373252159aebcf9f6c1c5 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:07:47 +0100 Subject: [PATCH 12/33] Add pydantic settings schema alongside the StrictYAML one Stands up `config/_schema.py` as a faithful translation of the existing StrictYAML schema, declaring every configuration setting as a pydantic model. Nothing is wired in yet: both schemas currently coexist, so the two can be diffed against each other before the loading path is switched over. The pydantic schema is verified to match the StrictYAML schema exactly: 32 leaf settings, identical keys and identical required-ness. Deliberate deviations from the StrictYAML schema: - The `logging:discord-channel` section is now genuinely optional. Previously it carried a default that omitted its own required `webhook-url` key, so writing a `logging:` section without a `discord-channel:` section failed to load, reporting a location of `"None", line 1`. - Empty delay/interval strings are rejected rather than parsed as a zero-length duration, which would cause any task looping upon it to spin without pausing. - Values failing to match the delay/interval format now raise directly, rather than falling through to pydantic, which would otherwise also accept ISO-8601 durations and `HH:MM:SS` strings that the documented format does not allow. Help text, whether a restart is required, and whether a value is secret are now declared as field metadata, so that the `/config` command can be driven from the schema itself rather than from a separately maintained mapping. Secrets are held as `SecretStr`, keeping them out of reprs, logs and dumps. Also enables the pydantic mypy plugin. Model declarations are exempted from `disallow_any_explicit`, which is incompatible with subclassing `BaseModel`. --- config/_schema.py | 630 ++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 11 +- uv.lock | 75 ++++++ 3 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 config/_schema.py diff --git a/config/_schema.py b/config/_schema.py new file mode 100644 index 000000000..d2ccc3cbd --- /dev/null +++ b/config/_schema.py @@ -0,0 +1,630 @@ +""" +Pydantic schema declaring every configuration setting TeX-Bot understands. + +This module is the single source of truth for the deployment configuration: +the shape of `tex-bot-deployment.yaml`, the type & constraints of every setting, +each setting's default value, and the metadata used to render the `/config` command +(help text, whether a restart is required, and whether the value is a secret). + +Nothing in this module reads or writes files; it only describes & validates data. + +NOTE: Annotations here are deliberately *not* deferred into `if TYPE_CHECKING:` blocks. +Pydantic evaluates field annotations at model-build time, +so every name used within a field annotation must be importable at runtime. +""" + +import datetime +import re +from enum import StrEnum +from typing import TYPE_CHECKING, Annotated, Literal + +from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + HttpUrl, + SecretStr, # noqa: TC002 # NOTE: Pydantic resolves field annotations at runtime +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + +__all__: "Sequence[str]" = ( + "AutoCookieCheckingSettings", + "CommandsSettings", + "CommunityGroupSettings", + "ConsoleLoggingSettings", + "DiscordAPILoggingSettings", + "DiscordChannelLoggingSettings", + "DiscordSettings", + "LinksSettings", + "LogLevel", + "LoggingSettings", + "MSLSettings", + "PingCommandSettings", + "ReminderSettings", + "RemindersSettings", + "SendIntroductionRemindersFlag", + "SendIntroductionRemindersSettings", + "SettingsSchema", + "StatsCommandSettings", + "StrikeCommandSettings", +) + + +class LogLevel(StrEnum): + """The set of valid values for any log-level configuration setting.""" + + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +SendIntroductionRemindersFlag = Literal["once", "interval", False] + + +def _to_kebab_case(field_name: str) -> str: + """Convert a Python field name into the kebab-case key used within the YAML file.""" + return field_name.replace("_", "-") + + +def _parse_log_level(value: object) -> object: + """Normalise a raw log-level value, matching it case & punctuation agnostically.""" + if not isinstance(value, str): + return value + + return value.upper().strip(" \n\t-_.") + + +def _parse_send_introduction_reminders_flag(value: object) -> object: + """ + Normalise a raw send-introduction-reminders value into its canonical form. + + Truthy boolean-like values are treated as `"once"`, + because sending a single reminder is the historic behaviour of enabling this setting. + """ + if isinstance(value, bool): + return "once" if value else False + + if not isinstance(value, str): + return value + + NORMALISED_VALUE: str = value.lower().strip() + + if NORMALISED_VALUE in ("once", "interval"): + return NORMALISED_VALUE + + if NORMALISED_VALUE in ("true", "yes", "on", "1"): + return "once" + + if NORMALISED_VALUE in ("false", "no", "off", "0"): + return False + + return value + + +_TIME_DELTA_MATCHER: "re.Pattern[str]" = re.compile( + r"\A(?:(?P(?:\d*\.)?\d+)s)?" + r"(?:(?P(?:\d*\.)?\d+)m)?" + r"(?:(?P(?:\d*\.)?\d+)h)?" + r"(?:(?P(?:\d*\.)?\d+)d)?\Z" +) + + +def _parse_time_delta(value: object) -> object: + """ + Parse a delay/interval string (in the format `smhd`). + + NOTE: Time resolutions must be given in ascending order of size + (so `30m1h` is accepted, whereas `1h30m` is not). + This matches the format previously accepted by the StrictYAML implementation. + + NOTE: A deviation from that previous implementation: + an empty string is rejected rather than silently parsed as a zero-length duration. + A zero-length interval would cause any task looping upon it to spin without pausing. + """ + if not isinstance(value, str): + return value + + match: re.Match[str] | None = _TIME_DELTA_MATCHER.fullmatch(value.strip()) + if match is None or not any(match.groupdict().values()): + # NOTE: Raising here (rather than deferring to Pydantic's own timedelta parsing) + # keeps the set of accepted values identical to the documented format: + # Pydantic would otherwise also accept ISO-8601 durations & `HH:MM:SS` strings. + INVALID_TIME_DELTA_MESSAGE: str = ( + "Value should be a delay/interval string, in the format " + "'smhd' " + "(with time resolutions given in ascending order of size)" + ) + raise ValueError(INVALID_TIME_DELTA_MESSAGE) + + return datetime.timedelta( + **{ + time_resolution_name: float(raw_time_resolution_value) + for time_resolution_name, raw_time_resolution_value in match.groupdict().items() + if raw_time_resolution_value + } + ) + + +def _ensure_discord_webhook_url(value: HttpUrl) -> HttpUrl: + """Ensure the given URL refers to a Discord webhook.""" + if not str(value).startswith("https://discord.com/api/webhooks/"): + NOT_A_DISCORD_WEBHOOK_URL_MESSAGE: str = ( + "Value should be a Discord webhook URL " + "(beginning with 'https://discord.com/api/webhooks/')" + ) + raise ValueError(NOT_A_DISCORD_WEBHOOK_URL_MESSAGE) + + return value + + +def _ensure_matches( + value: SecretStr, matcher: "re.Pattern[str]", expected_description: str +) -> SecretStr: + """ + Ensure the given secret value matches the given pattern. + + The secret value itself is never included within the raised error message. + """ + if matcher.fullmatch(value.get_secret_value()) is None: + NON_MATCHING_VALUE_MESSAGE: str = f"Value should be {expected_description}" + raise ValueError(NON_MATCHING_VALUE_MESSAGE) + + return value + + +def _ensure_unique(value: tuple[str, ...]) -> tuple[str, ...]: + """Ensure the given sequence of values contains no duplicates.""" + if len(set(value)) != len(value): + DUPLICATE_VALUES_MESSAGE: str = "Value should be a sequence of unique values" + raise ValueError(DUPLICATE_VALUES_MESSAGE) + + return value + + +type TimeDelta = Annotated[datetime.timedelta, BeforeValidator(_parse_time_delta)] +type UniqueStrSequence = Annotated[tuple[str, ...], AfterValidator(_ensure_unique)] +type DiscordWebhookURL = Annotated[HttpUrl, AfterValidator(_ensure_discord_webhook_url)] +type DiscordSnowflake = Annotated[int, Field(ge=10**16, lt=10**20)] +type NormalisedLogLevel = Annotated[LogLevel, BeforeValidator(_parse_log_level)] + + +class _BaseSettingsSchema(BaseModel): + """Common configuration shared by every section of the settings schema.""" + + model_config = ConfigDict( + alias_generator=_to_kebab_case, + populate_by_name=True, + extra="forbid", + frozen=True, + # NOTE: Pydantic's default regex engine is Rust's, which rejects Python-only anchors + # (such as `\Z`). Using Python's own engine keeps every pattern below identical to + # the one it replaces, rather than requiring subtly different equivalents. + regex_engine="python-re", + ) + + +class ConsoleLoggingSettings(_BaseSettingsSchema): + """Settings controlling how logs are emitted to the console output stream.""" + + log_level: NormalisedLogLevel = Field( + default=LogLevel.INFO, + description=( + "The minimum level that logs must meet " + "in order to be logged to the console output stream." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + + +class DiscordChannelLoggingSettings(_BaseSettingsSchema): + """Settings controlling how logs are relayed to a Discord log channel.""" + + webhook_url: DiscordWebhookURL = Field( + description=( + "The webhook URL of the Discord text channel where error logs should be sent.\n" + "Error logs will always be sent to the console; " + "this setting allows them to also be sent to a Discord log channel." + ), + json_schema_extra={"requires_restart": False, "secret": True}, + ) + log_level: NormalisedLogLevel = Field( + default=LogLevel.WARNING, + description=( + "The minimum level that logs must meet " + "in order to be logged to the Discord log channel." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + + +class DiscordAPILoggingSettings(_BaseSettingsSchema): + """Settings controlling how logs emitted by the Discord API wrapper are handled.""" + + enabled: bool = Field( + default=False, + description="Whether logs emitted by the Discord API wrapper should be recorded.", + json_schema_extra={"requires_restart": False, "secret": False}, + ) + log_level: NormalisedLogLevel = Field( + default=LogLevel.INFO, + description=( + "The minimum level that Discord API logs must meet in order to be recorded." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + file_name: str = Field( + default="discord.log", + min_length=1, + description="The name of the file that Discord API logs should be written to.", + json_schema_extra={"requires_restart": False, "secret": False}, + ) + + +class LoggingSettings(_BaseSettingsSchema): + """Settings controlling every logging destination TeX-Bot can write to.""" + + console: ConsoleLoggingSettings = Field(default_factory=ConsoleLoggingSettings) + discord_channel: DiscordChannelLoggingSettings | None = Field( + default=None, + description=( + "Settings for relaying error logs to a Discord log channel. " + "Omit this section entirely to disable Discord log-channel logging." + ), + ) + discord_api: DiscordAPILoggingSettings = Field(default_factory=DiscordAPILoggingSettings) + + +class DiscordSettings(_BaseSettingsSchema): + """Settings describing how TeX-Bot connects to Discord.""" + + bot_token: Annotated[ + SecretStr, + AfterValidator( + lambda token: _ensure_matches( + token, + re.compile( + r"\A(?!.*__.*)(?!.*--.*)" + r"(?:([A-Za-z0-9]{24,26})\.([A-Za-z0-9]{6})\.([A-Za-z0-9_-]{27,38}))\Z" + ), + "a Discord bot token", + ) + ), + ] = Field( + description=( + "The Discord token for the bot you created (available on your bot's page " + "within the Discord developer portal: " + ")." + ), + json_schema_extra={"requires_restart": True, "secret": True}, + ) + main_guild_id: DiscordSnowflake = Field( + description="The ID of your community group's main Discord guild.", + json_schema_extra={"requires_restart": True, "secret": False}, + ) + + +class LinksSettings(_BaseSettingsSchema): + """Settings holding the external links referenced within TeX-Bot's messages.""" + + purchase_membership: HttpUrl | None = Field( + default=None, + description=( + "The link to the page where guests can purchase a full membership " + "to join your community group." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + membership_perks: HttpUrl | None = Field( + default=None, + description=( + "The link to the page where guests can find out information about the perks " + "they will receive once they purchase a membership to your community group." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + moderation_policy: HttpUrl | None = Field( + default=None, + description="The link to your group's Discord guild moderation policy document.", + json_schema_extra={"requires_restart": False, "secret": False}, + ) + custom_discord_invite_link: HttpUrl | None = Field( + default=None, + description=( + "A custom invite link to your group's Discord guild, " + "used in place of one generated by TeX-Bot." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + + +class AutoCookieCheckingSettings(_BaseSettingsSchema): + """Settings controlling the automatic checking of the MSL authentication cookie.""" + + enabled: bool = Field( + default=False, + description=( + "Whether the MSL authentication cookie should be automatically checked " + "to determine whether it is still valid." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + interval: TimeDelta = Field( + default=datetime.timedelta(minutes=10), + description="The interval of time between checking the MSL authentication cookie.", + json_schema_extra={"requires_restart": False, "secret": False}, + ) + + +class MSLSettings(_BaseSettingsSchema): + """Settings describing how TeX-Bot authenticates with your group's MSL website.""" + + organisation_id: str | None = Field( + default=None, + pattern=r"\A\d{4,5}\Z", + description="The ID of your community group's organisation on your MSL website.", + json_schema_extra={"requires_restart": False, "secret": False}, + ) + auth_cookie: ( + Annotated[ + SecretStr, + AfterValidator( + lambda cookie: _ensure_matches( + cookie, + re.compile(r"\A[\w-]{512,1024}\Z"), + "an MSL authentication cookie", + ) + ), + ] + | None + ) = Field( + default=None, + description=( + "The MSL authentication session cookie.\n" + "This should authenticate TeX-Bot to view your group's members-list, " + "as if it were logged in to the website as a committee member.\n" + "If your members-list is found on the UoB Guild of Students website, " + "this can be extracted from your web-browser after manually logging in: " + "it will probably be listed as a cookie named `.ASPXAUTH`." + ), + json_schema_extra={"requires_restart": False, "secret": True}, + ) + auto_cookie_checking: AutoCookieCheckingSettings = Field( + default_factory=AutoCookieCheckingSettings + ) + + +class CommunityGroupSettings(_BaseSettingsSchema): + """Settings describing the community group that TeX-Bot is deployed for.""" + + full_name: str | None = Field( + default=None, + pattern=r"\A.{1,50}\Z", + description=( + "The full name of your community group; do **NOT** use an abbreviation.\n" + "This is substituted into many error/welcome messages " + "sent into your Discord guild by TeX-Bot.\n" + "If this is not set, the group's full name will be retrieved " + "from the name of your group's Discord guild." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + short_name: str | None = Field( + default=None, + pattern=r"\A(?!.*['&!?:,.#%\"-]['&!?:,.#%\"-].*)(?:[A-Za-z0-9'&!?:,.#%\"-]+)\Z", + description=( + "The short colloquial name of your community group; it is recommended " + "that you set this to be an abbreviation of your group's name.\n" + "If this is not set, the group's short name will be determined " + "from your group's full name." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + membership_dependent_roles: UniqueStrSequence | None = Field( + default=None, + description=( + "The names of the roles that should only be held " + "by members of your community group." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + links: LinksSettings + msl: MSLSettings + + +class PingCommandSettings(_BaseSettingsSchema): + """Settings controlling the behaviour of the `/ping` command.""" + + easter_egg_probability: float = Field( + default=0.01, + ge=0, + le=1, + description=( + "The probability that the rarer ping command response will be sent " + "instead of the normal one." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + + +class StatsCommandSettings(_BaseSettingsSchema): + """Settings controlling the behaviour of the `/stats` command.""" + + lookback_days: float = Field( + default=30.0, + ge=5, + le=1826, + description=( + "The number of days to look back over messages sent, to generate statistics data." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + displayed_roles: UniqueStrSequence = Field( + default=( + "Committee", + "Committee-Elect", + "Student Rep", + "Member", + "Guest", + "Server Booster", + "Foundation Year", + "First Year", + "Second Year", + "Final Year", + "Year In Industry", + "Year Abroad", + "PGT", + "PGR", + "Alumnus/Alumna", + "Postdoc", + "Quiz Victor", + ), + description=( + "The names of the roles to gather statistics about, to display in bar charts." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + + +class StrikeCommandSettings(_BaseSettingsSchema): + """Settings controlling the behaviour of the `/strike` command.""" + + performed_manually_warning_location: str = Field( + default="DM", + min_length=1, + description=( + "The name of the channel that warning messages will be sent to, when a " + "committee-member manually applies a moderation action " + "(instead of using the `/strike` command).\n" + "This can be the name of **ANY** Discord channel " + "(so the offending person *will* be able to see these messages " + "if a public channel is chosen), or the value `DM` " + "(which indicates the messages will be sent to the committee-member's DMs)." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + timeout_duration: TimeDelta = Field( + default=datetime.timedelta(hours=24), + description=( + "The amount of time to timeout a user for, when using the `/strike` command." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + reported_message_destination_channel: str = Field( + default="discord", + min_length=1, + description=( + "The name of the channel that reported messages should be sent to for review." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + + +class CommandsSettings(_BaseSettingsSchema): + """Settings controlling the behaviour of TeX-Bot's individual commands.""" + + ping: PingCommandSettings = Field(default_factory=PingCommandSettings) + stats: StatsCommandSettings = Field(default_factory=StatsCommandSettings) + strike: StrikeCommandSettings = Field(default_factory=StrikeCommandSettings) + + +class SendIntroductionRemindersSettings(_BaseSettingsSchema): + """Settings controlling the reminders sent to Discord members that are not inducted.""" + + enabled: Annotated[ + SendIntroductionRemindersFlag, + BeforeValidator(_parse_send_introduction_reminders_flag), + ] = Field( + default="once", + description=( + "Whether introduction reminders will be sent to Discord members " + "that are not inducted, saying that they need to send an introduction " + "to be allowed access.\n" + "Use `once` to send a single reminder, `interval` to send them repeatedly, " + "or `false` to disable them entirely." + ), + json_schema_extra={"requires_restart": True, "secret": False}, + ) + delay: TimeDelta = Field( + default=datetime.timedelta(hours=40), + description=( + "How long to wait after a user joins your guild, before sending them " + "the first/only message reminding them to send an introduction.\n" + "Is ignored if `enabled` **=** `false`." + ), + json_schema_extra={"requires_restart": True, "secret": False}, + ) + interval: TimeDelta = Field( + default=datetime.timedelta(hours=6), + description=( + "The interval of time between sending out reminders to Discord members " + "that are not inducted.\n" + "Is ignored unless `enabled` **=** `interval`." + ), + json_schema_extra={"requires_restart": True, "secret": False}, + ) + + +class ReminderSettings(_BaseSettingsSchema): + """Settings controlling the reminders sent to Discord members that have been inducted.""" + + enabled: bool = Field( + default=True, + description=( + "Whether reminders will be sent to Discord members that have been inducted, " + "saying that they can get opt-in roles. " + "(This message will only be sent once per Discord member.)" + ), + json_schema_extra={"requires_restart": True, "secret": False}, + ) + delay: TimeDelta = Field( + default=datetime.timedelta(hours=40), + description=( + "How long to wait after a user is inducted, before sending them the message " + "telling them to get some opt-in roles.\n" + "Is ignored if `enabled` **=** `false`." + ), + json_schema_extra={"requires_restart": True, "secret": False}, + ) + interval: TimeDelta = Field( + default=datetime.timedelta(hours=6), + description=( + "The interval of time between checking for Discord members " + "that should be sent a get-roles reminder.\n" + "Is ignored if `enabled` **=** `false`." + ), + json_schema_extra={"requires_restart": True, "secret": False}, + ) + + +class RemindersSettings(_BaseSettingsSchema): + """Settings controlling every kind of reminder that TeX-Bot can send.""" + + send_introduction_reminders: SendIntroductionRemindersSettings = Field( + default_factory=SendIntroductionRemindersSettings + ) + send_get_roles_reminders: ReminderSettings = Field(default_factory=ReminderSettings) + + +class SettingsSchema(_BaseSettingsSchema): + """The complete set of configuration settings that TeX-Bot understands.""" + + logging: LoggingSettings = Field(default_factory=LoggingSettings) + discord: DiscordSettings + community_group: CommunityGroupSettings + commands: CommandsSettings = Field(default_factory=CommandsSettings) + reminders: RemindersSettings = Field(default_factory=RemindersSettings) + auto_add_committee_to_threads: bool = Field( + default=True, + description=( + "Whether committee members should be automatically added " + "to any newly created threads." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) diff --git a/pyproject.toml b/pyproject.toml index ea00921ab..61ea8c644 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,8 +24,10 @@ main = [ "mplcyberpunk>=0.7", "parsedatetime>=2.6", "py-cord>=2.6,<2.7", + "pydantic>=2.13", "python-dotenv>=1.0", "python-logging-discord-handler>=0.1", + "ruamel-yaml>=0.19", "strictyaml>=1.7.3", "typed_classproperties>=1.2", "validators>=0.34" @@ -78,7 +80,7 @@ enable_error_code = [ extra_checks = true mypy_path = "stubs" no_implicit_reexport = true -plugins = ["mypy_django_plugin.main"] +plugins = ["mypy_django_plugin.main", "pydantic.mypy"] strict_bytes = true strict_equality = true strict_equality_for_none = true @@ -93,6 +95,13 @@ warn_incomplete_stub = true ignore_errors = true module = ["db.core.migrations.*"] +# NOTE: Subclassing Pydantic's `BaseModel` is incompatible with `disallow_any_explicit`: +# `BaseModel`'s own API surface (`__init__(**data: Any)`, `model_validate(obj: Any)`, etc.) +# contains explicit `Any`, so every model declaration is flagged regardless of its fields. +[[tool.mypy.overrides]] +disable_error_code = ["explicit-any"] +module = ["config._schema"] + [tool.pymarkdown] extensions.front-matter.enabled = true mode.strict-config = true diff --git a/uv.lock b/uv.lock index 253e323c8..ab2cb101a 100644 --- a/uv.lock +++ b/uv.lock @@ -88,6 +88,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.14.2" @@ -859,6 +868,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/38/56b67abdbf6797475dfe2f62d391b4a6ead851c76acbaf07e118e53651b6/py_walk-0.3.3-py3-none-any.whl", hash = "sha256:238fc018165138021ce0bfd9c351cdc473d3120ccc5534df35611b92608c94d5", size = 14537, upload-time = "2024-10-26T14:30:38.06Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1037,6 +1087,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + [[package]] name = "ruff" version = "0.15.22" @@ -1143,8 +1202,10 @@ main = [ { name = "mplcyberpunk" }, { name = "parsedatetime" }, { name = "py-cord" }, + { name = "pydantic" }, { name = "python-dotenv" }, { name = "python-logging-discord-handler" }, + { name = "ruamel-yaml" }, { name = "strictyaml" }, { name = "typed-classproperties" }, { name = "validators" }, @@ -1192,8 +1253,10 @@ main = [ { name = "mplcyberpunk", specifier = ">=0.7" }, { name = "parsedatetime", specifier = ">=2.6" }, { name = "py-cord", specifier = ">=2.6,<2.7" }, + { name = "pydantic", specifier = ">=2.13" }, { name = "python-dotenv", specifier = ">=1.0" }, { name = "python-logging-discord-handler", specifier = ">=0.1" }, + { name = "ruamel-yaml", specifier = ">=0.19" }, { name = "strictyaml", specifier = ">=1.7.3" }, { name = "typed-classproperties", specifier = ">=1.2" }, { name = "validators", specifier = ">=0.34" }, @@ -1296,6 +1359,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2026.3" From ce17101c8c95fa7200539cf870b84ac2186bd0fb Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:19:13 +0100 Subject: [PATCH 13/33] Accept durations largest-unit-first & narrow the explicit-any ignores Reverses the order that time resolutions must be given in, within delay/interval strings. Durations are now written largest-unit-first, so `1h30m` is accepted and `30m1h` is not; previously this was the other way around, inherited from the StrictYAML implementation. The full format is `dhms`. Replaces the module-wide `explicit-any` exemption with a per-class `# type: ignore[explicit-any]` on each model. Subclassing Pydantic's `BaseModel` is inherently incompatible with `disallow_any_explicit`, because `BaseModel`'s own API surface exposes explicit `Any`, but suppressing the error code for the whole module would also hide any genuine use of `Any` added here later. Ignoring it per-class keeps the check active everywhere else within the module. --- config/_schema.py | 67 +++++++++++++++++++++++++++-------------------- pyproject.toml | 7 ----- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/config/_schema.py b/config/_schema.py index d2ccc3cbd..dc4a2da89 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -109,22 +109,24 @@ def _parse_send_introduction_reminders_flag(value: object) -> object: _TIME_DELTA_MATCHER: "re.Pattern[str]" = re.compile( - r"\A(?:(?P(?:\d*\.)?\d+)s)?" - r"(?:(?P(?:\d*\.)?\d+)m)?" + r"\A(?:(?P(?:\d*\.)?\d+)d)?" r"(?:(?P(?:\d*\.)?\d+)h)?" - r"(?:(?P(?:\d*\.)?\d+)d)?\Z" + r"(?:(?P(?:\d*\.)?\d+)m)?" + r"(?:(?P(?:\d*\.)?\d+)s)?\Z" ) def _parse_time_delta(value: object) -> object: """ - Parse a delay/interval string (in the format `smhd`). + Parse a delay/interval string (in the format `dhms`). + + NOTE: Time resolutions must be given in descending order of size + (so `1h30m` is accepted, whereas `30m1h` is not). - NOTE: Time resolutions must be given in ascending order of size - (so `30m1h` is accepted, whereas `1h30m` is not). - This matches the format previously accepted by the StrictYAML implementation. + NOTE: A deviation from the previous StrictYAML implementation, which required time + resolutions in *ascending* order of size, so accepted `30m1h` but rejected `1h30m`. - NOTE: A deviation from that previous implementation: + NOTE: A further deviation from that previous implementation: an empty string is rejected rather than silently parsed as a zero-length duration. A zero-length interval would cause any task looping upon it to spin without pausing. """ @@ -138,8 +140,8 @@ def _parse_time_delta(value: object) -> object: # Pydantic would otherwise also accept ISO-8601 durations & `HH:MM:SS` strings. INVALID_TIME_DELTA_MESSAGE: str = ( "Value should be a delay/interval string, in the format " - "'smhd' " - "(with time resolutions given in ascending order of size)" + "'dhms' " + "(with time resolutions given in descending order of size)" ) raise ValueError(INVALID_TIME_DELTA_MESSAGE) @@ -196,7 +198,16 @@ def _ensure_unique(value: tuple[str, ...]) -> tuple[str, ...]: class _BaseSettingsSchema(BaseModel): - """Common configuration shared by every section of the settings schema.""" + """ + Common configuration shared by every section of the settings schema. + + NOTE: Every subclass below carries a `# type: ignore[explicit-any]` comment. + Pydantic's `BaseModel` exposes explicit `Any` within its own API surface + (`__init__(**data: Any)`, `model_validate(obj: Any)`, etc.), + so subclassing it is inherently incompatible with `disallow_any_explicit`. + The ignores are applied per-class, rather than by disabling the error code + for this whole module, so that any genuine use of `Any` here is still caught. + """ model_config = ConfigDict( alias_generator=_to_kebab_case, @@ -210,7 +221,7 @@ class _BaseSettingsSchema(BaseModel): ) -class ConsoleLoggingSettings(_BaseSettingsSchema): +class ConsoleLoggingSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling how logs are emitted to the console output stream.""" log_level: NormalisedLogLevel = Field( @@ -223,7 +234,7 @@ class ConsoleLoggingSettings(_BaseSettingsSchema): ) -class DiscordChannelLoggingSettings(_BaseSettingsSchema): +class DiscordChannelLoggingSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling how logs are relayed to a Discord log channel.""" webhook_url: DiscordWebhookURL = Field( @@ -244,7 +255,7 @@ class DiscordChannelLoggingSettings(_BaseSettingsSchema): ) -class DiscordAPILoggingSettings(_BaseSettingsSchema): +class DiscordAPILoggingSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling how logs emitted by the Discord API wrapper are handled.""" enabled: bool = Field( @@ -267,7 +278,7 @@ class DiscordAPILoggingSettings(_BaseSettingsSchema): ) -class LoggingSettings(_BaseSettingsSchema): +class LoggingSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling every logging destination TeX-Bot can write to.""" console: ConsoleLoggingSettings = Field(default_factory=ConsoleLoggingSettings) @@ -281,7 +292,7 @@ class LoggingSettings(_BaseSettingsSchema): discord_api: DiscordAPILoggingSettings = Field(default_factory=DiscordAPILoggingSettings) -class DiscordSettings(_BaseSettingsSchema): +class DiscordSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings describing how TeX-Bot connects to Discord.""" bot_token: Annotated[ @@ -310,7 +321,7 @@ class DiscordSettings(_BaseSettingsSchema): ) -class LinksSettings(_BaseSettingsSchema): +class LinksSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings holding the external links referenced within TeX-Bot's messages.""" purchase_membership: HttpUrl | None = Field( @@ -344,7 +355,7 @@ class LinksSettings(_BaseSettingsSchema): ) -class AutoCookieCheckingSettings(_BaseSettingsSchema): +class AutoCookieCheckingSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling the automatic checking of the MSL authentication cookie.""" enabled: bool = Field( @@ -362,7 +373,7 @@ class AutoCookieCheckingSettings(_BaseSettingsSchema): ) -class MSLSettings(_BaseSettingsSchema): +class MSLSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings describing how TeX-Bot authenticates with your group's MSL website.""" organisation_id: str | None = Field( @@ -400,7 +411,7 @@ class MSLSettings(_BaseSettingsSchema): ) -class CommunityGroupSettings(_BaseSettingsSchema): +class CommunityGroupSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings describing the community group that TeX-Bot is deployed for.""" full_name: str | None = Field( @@ -438,7 +449,7 @@ class CommunityGroupSettings(_BaseSettingsSchema): msl: MSLSettings -class PingCommandSettings(_BaseSettingsSchema): +class PingCommandSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling the behaviour of the `/ping` command.""" easter_egg_probability: float = Field( @@ -453,7 +464,7 @@ class PingCommandSettings(_BaseSettingsSchema): ) -class StatsCommandSettings(_BaseSettingsSchema): +class StatsCommandSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling the behaviour of the `/stats` command.""" lookback_days: float = Field( @@ -492,7 +503,7 @@ class StatsCommandSettings(_BaseSettingsSchema): ) -class StrikeCommandSettings(_BaseSettingsSchema): +class StrikeCommandSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling the behaviour of the `/strike` command.""" performed_manually_warning_location: str = Field( @@ -526,7 +537,7 @@ class StrikeCommandSettings(_BaseSettingsSchema): ) -class CommandsSettings(_BaseSettingsSchema): +class CommandsSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling the behaviour of TeX-Bot's individual commands.""" ping: PingCommandSettings = Field(default_factory=PingCommandSettings) @@ -534,7 +545,7 @@ class CommandsSettings(_BaseSettingsSchema): strike: StrikeCommandSettings = Field(default_factory=StrikeCommandSettings) -class SendIntroductionRemindersSettings(_BaseSettingsSchema): +class SendIntroductionRemindersSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling the reminders sent to Discord members that are not inducted.""" enabled: Annotated[ @@ -571,7 +582,7 @@ class SendIntroductionRemindersSettings(_BaseSettingsSchema): ) -class ReminderSettings(_BaseSettingsSchema): +class ReminderSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling the reminders sent to Discord members that have been inducted.""" enabled: bool = Field( @@ -603,7 +614,7 @@ class ReminderSettings(_BaseSettingsSchema): ) -class RemindersSettings(_BaseSettingsSchema): +class RemindersSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling every kind of reminder that TeX-Bot can send.""" send_introduction_reminders: SendIntroductionRemindersSettings = Field( @@ -612,7 +623,7 @@ class RemindersSettings(_BaseSettingsSchema): send_get_roles_reminders: ReminderSettings = Field(default_factory=ReminderSettings) -class SettingsSchema(_BaseSettingsSchema): +class SettingsSchema(_BaseSettingsSchema): # type: ignore[explicit-any] """The complete set of configuration settings that TeX-Bot understands.""" logging: LoggingSettings = Field(default_factory=LoggingSettings) diff --git a/pyproject.toml b/pyproject.toml index 61ea8c644..e7af695f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,13 +95,6 @@ warn_incomplete_stub = true ignore_errors = true module = ["db.core.migrations.*"] -# NOTE: Subclassing Pydantic's `BaseModel` is incompatible with `disallow_any_explicit`: -# `BaseModel`'s own API surface (`__init__(**data: Any)`, `model_validate(obj: Any)`, etc.) -# contains explicit `Any`, so every model declaration is flagged regardless of its fields. -[[tool.mypy.overrides]] -disable_error_code = ["explicit-any"] -module = ["config._schema"] - [tool.pymarkdown] extensions.front-matter.enabled = true mode.strict-config = true From 91c804ca191c8eb81c8f52aa817f8ad10b65bda3 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:28:05 +0100 Subject: [PATCH 14/33] Add comment-preserving reader/writer for the configuration file Adds `config/_document.py`, which owns every interaction with `tex-bot-deployment.yaml` as a document: locating it, parsing it via ruamel.yaml so that comments & formatting survive, writing it back, and mapping validation failures onto the lines that caused them. It is not wired in yet. Parsing a file and writing it straight back is byte-identical, and changing individual values leaves surrounding comments (including trailing inline comments) intact, which is what allows `/config set` to edit a hand-written file without destroying it. Writes go to a temporary file alongside the destination and are then moved into place, so that failing partway through cannot leave a truncated configuration file behind, and so that a reader never observes a half-written file. Validation failures are rendered with the file & line that caused them, resolved from ruamel's position data. Where a key is absent entirely, the line of the deepest resolvable parent is reported instead, so the message still points at the relevant section. Offending values are excluded from these messages, so that secrets cannot reach logs or a Discord channel. Locating the file uses a single `TEX_BOT_CONFIG_PATH` environment variable, falling back to `tex-bot-deployment.yaml` in the project root, rather than the twelve interchangeable environment variables previously proposed. --- config/_document.py | 256 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 config/_document.py diff --git a/config/_document.py b/config/_document.py new file mode 100644 index 000000000..549ba6b06 --- /dev/null +++ b/config/_document.py @@ -0,0 +1,256 @@ +""" +Reading, writing & error-reporting for the raw deployment configuration file. + +This module owns every interaction with `tex-bot-deployment.yaml` as a *document*: +locating it, parsing it while retaining its comments & formatting, writing it back +atomically, and mapping validation failures onto the lines that caused them. + +It deliberately knows nothing about what any individual setting means; +the shape & meaning of the configuration is declared solely within `config._schema`. +""" + +import io +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap +from ruamel.yaml.error import YAMLError + +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + from typing import Final, Self + + from pydantic import ValidationError + + +__all__: "Sequence[str]" = ( + "SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME", + "InvalidSettingsFileError", + "SettingsDocument", + "SettingsFileNotFoundError", + "get_settings_file_path", +) + + +PROJECT_ROOT: "Final[Path]" = Path(__file__).parent.parent.resolve() + +DEFAULT_SETTINGS_FILE_NAME: "Final[str]" = "tex-bot-deployment.yaml" + +SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME: "Final[str]" = "TEX_BOT_CONFIG_PATH" + + +class SettingsFileNotFoundError(Exception): + """Exception class to raise when no deployment configuration file could be located.""" + + +class InvalidSettingsFileError(Exception): + """Exception class to raise when the deployment configuration file could not be read.""" + + +def get_settings_file_path() -> Path: + """ + Locate the deployment configuration file. + + The location is taken from the `TEX_BOT_CONFIG_PATH` environment variable if it is set, + otherwise `tex-bot-deployment.yaml` alongside the project root is used. + """ + RAW_SETTINGS_FILE_PATH: Final[str | None] = os.getenv( + SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME + ) + + if RAW_SETTINGS_FILE_PATH: + settings_file_path: Path = Path(RAW_SETTINGS_FILE_PATH).expanduser() + if not settings_file_path.is_file(): + SETTINGS_FILE_FROM_ENVIRONMENT_NOT_FOUND_MESSAGE: str = ( + f"The path given by the " + f"{SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME} environment variable " + f"does not refer to an existing file: {str(settings_file_path)!r}." + ) + raise SettingsFileNotFoundError(SETTINGS_FILE_FROM_ENVIRONMENT_NOT_FOUND_MESSAGE) + + return settings_file_path.resolve() + + default_settings_file_path: Path = PROJECT_ROOT / DEFAULT_SETTINGS_FILE_NAME + if not default_settings_file_path.is_file(): + NO_SETTINGS_FILE_MESSAGE: str = ( + f"No configuration file was found. Create a {DEFAULT_SETTINGS_FILE_NAME!r} file " + f"within {str(PROJECT_ROOT)!r}, or set the " + f"{SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME} environment variable " + f"to the location of your configuration file." + ) + raise SettingsFileNotFoundError(NO_SETTINGS_FILE_MESSAGE) + + return default_settings_file_path.resolve() + + +def _create_yaml_parser() -> YAML: + """Create a YAML parser that retains comments & formatting when writing documents back.""" + yaml_parser: YAML = YAML() # NOTE: Defaults to round-trip mode + yaml_parser.preserve_quotes = True + # NOTE: Without a generous line width, writing a document back would re-wrap any long + # values it contains, producing spurious changes within otherwise untouched sections. + yaml_parser.width = 4096 + + return yaml_parser + + +class SettingsDocument: + """ + A single deployment configuration file, held as a comment-preserving document. + + Holding the parsed document (rather than only the values taken from it) allows + individual settings to be rewritten without discarding the comments, ordering & + formatting of the file that a human wrote. + """ + + def __init__(self, file_path: Path, raw: "CommentedMap") -> None: + """Initialise a configuration document already parsed from the given file path.""" + self._file_path: Path = file_path + self._raw: CommentedMap = raw + + @classmethod + def load(cls, file_path: Path | None = None) -> "Self": + """Locate & parse the deployment configuration file.""" + RESOLVED_FILE_PATH: Final[Path] = ( + get_settings_file_path() if file_path is None else file_path + ) + + raw_file_contents: str = RESOLVED_FILE_PATH.read_text(encoding="utf-8") + + yaml_parse_error: YAMLError + try: + # NOTE: Annotated as `object` because an arbitrary YAML file need not contain a + # mapping at its top level: an empty file parses to `None`, and a file holding + # only a scalar or a sequence parses to that value. + raw: object = _create_yaml_parser().load(raw_file_contents) + except YAMLError as yaml_parse_error: + INVALID_YAML_MESSAGE: str = ( + f"{RESOLVED_FILE_PATH.name} is not a valid YAML file: {yaml_parse_error}" + ) + raise InvalidSettingsFileError(INVALID_YAML_MESSAGE) from yaml_parse_error + + if raw is None: + EMPTY_SETTINGS_FILE_MESSAGE: str = f"{RESOLVED_FILE_PATH.name} is empty." + raise InvalidSettingsFileError(EMPTY_SETTINGS_FILE_MESSAGE) + + # NOTE: Checking for `CommentedMap` (rather than merely `dict`) also guarantees the + # presence of the `lc` line-comment data that `line_number_of()` relies upon. + if not isinstance(raw, CommentedMap): + NOT_A_MAPPING_MESSAGE: str = ( + f"{RESOLVED_FILE_PATH.name} must contain a mapping of configuration " + f"settings at its top level, but contains " + f"{type(raw).__name__} instead." + ) + raise InvalidSettingsFileError(NOT_A_MAPPING_MESSAGE) + + return cls(file_path=RESOLVED_FILE_PATH, raw=raw) + + @property + def file_path(self) -> Path: + """The location of the file this document was parsed from.""" + return self._file_path + + @property + def raw(self) -> "CommentedMap": + """ + The parsed document, retaining the comments & formatting of the original file. + + Mutating this mapping does not write anything to disk; + `write()` must be called to persist any changes made. + """ + return self._raw + + def dump(self) -> str: + """Serialise this document back into YAML, retaining comments & formatting.""" + output_buffer: io.StringIO = io.StringIO() + _create_yaml_parser().dump(self._raw, output_buffer) + + return output_buffer.getvalue() + + def write(self) -> None: + """ + Persist this document to disk, atomically. + + The serialised document is written to a temporary file alongside the destination, + then moved into place, so that a failure partway through writing cannot leave a + truncated configuration file behind. + """ + NEW_FILE_CONTENTS: Final[str] = self.dump() + + temporary_file_path: Path = self._file_path.with_name( + f"{self._file_path.name}.{os.getpid()}.tmp" + ) + + try: + temporary_file_path.write_text(NEW_FILE_CONTENTS, encoding="utf-8") + # NOTE: `os.replace()` is atomic where the source & destination are located upon + # the same filesystem, which writing the temporary file alongside guarantees. + os.replace(temporary_file_path, self._file_path) # noqa: PTH105 + finally: + temporary_file_path.unlink(missing_ok=True) + + def line_number_of(self, key_path: "Sequence[str | int]") -> int | None: + """ + Return the line within the original file that the given sequence of keys refers to. + + Where the given keys cannot be fully resolved (because a required key is absent + from the file, for example), the line of the deepest key that *could* be resolved + is returned, so that an error can still be pointed at the relevant section. + """ + node: object = self._raw + deepest_known_line_number: int | None = None + + key: str | int + for key in key_path: + line_comments: object = getattr(node, "lc", None) + NODE_CONTAINS_KEY: bool = bool( + line_comments is not None + and isinstance(node, (dict, list)) + and (key in node if isinstance(node, dict) else False) + ) + if not NODE_CONTAINS_KEY: + break + + if TYPE_CHECKING: + assert isinstance(node, dict) + + # NOTE: `lc.key()` reports a zero-indexed line, whereas humans (& every editor) + # count the first line of a file as line 1. + deepest_known_line_number = node.lc.key(key)[0] + 1 # type: ignore[attr-defined] + node = node[key] + + return deepest_known_line_number + + def _format_single_error(self, key_path: "Sequence[str | int]", message: str) -> str: + """Format a single validation failure, prefixed with the location that caused it.""" + LINE_NUMBER: Final[int | None] = self.line_number_of(key_path) + + LOCATION: Final[str] = ( + f"{self._file_path.name}:{LINE_NUMBER}" + if LINE_NUMBER is not None + else self._file_path.name + ) + SETTING_NAME: Final[str] = ( + ":".join(str(key) for key in key_path) if key_path else "(whole file)" + ) + + return f"{LOCATION} {SETTING_NAME}\n {message}" + + def format_validation_error(self, validation_error: "ValidationError") -> str: + """ + Render a validation failure into a message suitable for a human to act upon. + + Every reported failure is annotated with the line of the configuration file that + caused it. The offending values themselves are deliberately excluded, so that + secrets cannot leak into logs or into any message sent to a Discord channel. + """ + formatted_errors: Iterator[str] = ( + self._format_single_error( + key_path=single_error["loc"], message=single_error["msg"] + ) + for single_error in validation_error.errors(include_input=False, include_url=False) + ) + + return "\n".join(formatted_errors) From c185a7989853558827ef72f52aba52077e360762 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:33:22 +0100 Subject: [PATCH 15/33] Add configuration accessor holding an immutable snapshot Adds `config/_accessor.py`, joining the schema & the document together: it reads the configuration file, validates it, and holds the result as a single immutable snapshot. It is not wired in yet. Reloading replaces that snapshot by rebinding one reference, so every reader sees either the whole of the previous configuration or the whole of the new one. This replaces the previously proposed approach of mutating settings one at a time through around twenty-five separate reload methods, where a failure partway through would leave the configuration half-applied with no way back. A reload that fails, because the file is unreadable or contains invalid settings, leaves the running configuration untouched, so that a bad edit cannot take a running bot down. Reloading also reports which settings actually changed, which is what the file watcher will use to decide what needs re-applying. Settings are reached by section as typed attributes, so `settings.discord.bot_token` is known to be a `SecretStr` and `settings.commands.strike.timeout_duration` a `timedelta`, rather than every value being typed as `object`. The token guard that prevented reading the bot token once running is not carried over. Holding the token as a `SecretStr` addresses the same concern more directly: it cannot be printed, logged or interpolated by accident, and reading it now requires an explicit `get_secret_value()` call. Also reports an explicitly given but non-existent configuration file path as a `SettingsFileNotFoundError`, rather than letting a bare `FileNotFoundError` escape. --- config/_accessor.py | 207 ++++++++++++++++++++++++++++++++++++++++++++ config/_document.py | 9 ++ 2 files changed, 216 insertions(+) create mode 100644 config/_accessor.py diff --git a/config/_accessor.py b/config/_accessor.py new file mode 100644 index 000000000..f52b62e16 --- /dev/null +++ b/config/_accessor.py @@ -0,0 +1,207 @@ +""" +Runtime access to the validated deployment configuration. + +Holds the currently-loaded configuration as a single immutable snapshot, replaced +wholesale whenever the configuration is reloaded. Because a reload swaps one reference +rather than mutating settings individually, no reader can ever observe a partially +applied configuration, even if reloading fails partway through. +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ValidationError + +from ._document import SettingsDocument +from ._schema import SettingsSchema + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from collections.abc import Set as AbstractSet + from pathlib import Path + from typing import Final + + from ._schema import ( + CommandsSettings, + CommunityGroupSettings, + DiscordSettings, + LoggingSettings, + RemindersSettings, + ) + + +__all__: "Sequence[str]" = ( + "SettingsAccessor", + "SettingsNotLoadedError", + "SettingsValidationError", +) + + +class SettingsNotLoadedError(Exception): + """Exception class to raise when configuration is accessed before it has been loaded.""" + + def __init__(self, message: str | None = None) -> None: + """Initialise a new SettingsNotLoadedError with the given message.""" + super().__init__( + message or "Configuration cannot be accessed before it has been loaded." + ) + + +class SettingsValidationError(Exception): + """Exception class to raise when the configuration file contains invalid settings.""" + + +@dataclass(frozen=True, slots=True) +class _LoadedSettings: + """ + A validated configuration snapshot, paired with the document it was parsed from. + + Holding both within a single frozen object allows a reload to replace them together, + by rebinding one reference, so the two can never disagree with one another. + """ + + snapshot: SettingsSchema + document: SettingsDocument + + +_MISSING: "Final[object]" = object() + + +def _flatten_settings(model: BaseModel, prefix: str = "") -> "Mapping[str, object]": + """ + Flatten a settings model into a mapping of colon-separated key paths to values. + + Nested sections are expanded recursively, so that + `{"discord": {"bot-token": ...}}` becomes `{"discord:bot-token": ...}`, + matching the key paths used by the `/config` command. + """ + flattened_settings: dict[str, object] = {} + + field_name: str + for field_name in type(model).model_fields: + value: object = getattr(model, field_name) + KEY_PATH: str = f"{prefix}{field_name.replace('_', '-')}" + + if isinstance(value, BaseModel): + flattened_settings.update(_flatten_settings(value, prefix=f"{KEY_PATH}:")) + else: + flattened_settings[KEY_PATH] = value + + return flattened_settings + + +class SettingsAccessor: + """ + Provides access to the validated configuration settings. + + Settings are accessed by section, as attributes + (for example: `settings.discord.bot_token`). + """ + + def __init__(self) -> None: + """Initialise an accessor holding no configuration until it is first loaded.""" + self._loaded: _LoadedSettings | None = None + + @property + def is_loaded(self) -> bool: + """Whether any configuration has been loaded yet.""" + return self._loaded is not None + + @property + def _current(self) -> _LoadedSettings: + """The currently-loaded configuration, raising if none has been loaded yet.""" + if self._loaded is None: + raise SettingsNotLoadedError + + return self._loaded + + @property + def file_path(self) -> "Path": + """The location of the configuration file that is currently loaded.""" + return self._current.document.file_path + + @property + def document(self) -> SettingsDocument: + """ + The document that the currently-loaded configuration was parsed from. + + Used to rewrite individual settings while retaining the comments & formatting + of the file that a human wrote. + """ + return self._current.document + + def reload(self, file_path: "Path | None" = None) -> "AbstractSet[str]": + """ + Load the configuration file, replacing any previously loaded configuration. + + Returns the set of settings key paths whose values differ from those previously + loaded. Every setting is reported as changed upon the very first load. + + The previously loaded configuration is left untouched if the file cannot be read + or contains invalid settings, so that a bad edit cannot take a running bot down. + """ + document: SettingsDocument = SettingsDocument.load(file_path) + + validation_error: ValidationError + try: + snapshot: SettingsSchema = SettingsSchema.model_validate(document.raw) + except ValidationError as validation_error: + raise SettingsValidationError( + document.format_validation_error(validation_error) + ) from validation_error + + PREVIOUS_SETTINGS: Final[Mapping[str, object]] = ( + {} if self._loaded is None else _flatten_settings(self._loaded.snapshot) + ) + NEW_SETTINGS: Final[Mapping[str, object]] = _flatten_settings(snapshot) + + # NOTE: Rebinding this single reference is what makes a reload atomic: every + # reader either sees the whole of the previous configuration, or the whole of the + # new one. Both the validation above & the file reading before it can raise, and + # neither will have left the currently-loaded configuration partially updated. + self._loaded = _LoadedSettings(snapshot=snapshot, document=document) + + return { + key_path + for key_path in (PREVIOUS_SETTINGS.keys() | NEW_SETTINGS.keys()) + if PREVIOUS_SETTINGS.get(key_path, _MISSING) + != NEW_SETTINGS.get(key_path, _MISSING) + } + + def as_flat_mapping(self) -> "Mapping[str, object]": + """ + Return every loaded setting, as a mapping of colon-separated key paths to values. + + Used by the `/config` command to view settings by name. + """ + return _flatten_settings(self._current.snapshot) + + @property + def logging(self) -> "LoggingSettings": + """Settings controlling every logging destination TeX-Bot can write to.""" + return self._current.snapshot.logging + + @property + def discord(self) -> "DiscordSettings": + """Settings describing how TeX-Bot connects to Discord.""" + return self._current.snapshot.discord + + @property + def community_group(self) -> "CommunityGroupSettings": + """Settings describing the community group that TeX-Bot is deployed for.""" + return self._current.snapshot.community_group + + @property + def commands(self) -> "CommandsSettings": + """Settings controlling the behaviour of TeX-Bot's individual commands.""" + return self._current.snapshot.commands + + @property + def reminders(self) -> "RemindersSettings": + """Settings controlling every kind of reminder that TeX-Bot can send.""" + return self._current.snapshot.reminders + + @property + def auto_add_committee_to_threads(self) -> bool: + """Whether committee members are automatically added to newly created threads.""" + return self._current.snapshot.auto_add_committee_to_threads diff --git a/config/_document.py b/config/_document.py index 549ba6b06..a5d325292 100644 --- a/config/_document.py +++ b/config/_document.py @@ -117,6 +117,15 @@ def load(cls, file_path: Path | None = None) -> "Self": get_settings_file_path() if file_path is None else file_path ) + # NOTE: `get_settings_file_path()` has already checked that the file it returns + # exists, but an explicitly given path has not been checked by anything. Checking + # here keeps every failure to read the configuration reportable as one error type. + if not RESOLVED_FILE_PATH.is_file(): + EXPLICIT_SETTINGS_FILE_NOT_FOUND_MESSAGE: str = ( + f"No configuration file exists at {str(RESOLVED_FILE_PATH)!r}." + ) + raise SettingsFileNotFoundError(EXPLICIT_SETTINGS_FILE_NOT_FOUND_MESSAGE) + raw_file_contents: str = RESOLVED_FILE_PATH.read_text(encoding="utf-8") yaml_parse_error: YAMLError From 317e87b01e2c485fa9bf7cb349eb74e47e5a0892 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:46:19 +0100 Subject: [PATCH 16/33] Load configuration from tex-bot-deployment.yaml Switches TeX-Bot over to the new configuration package and removes the old environment-variable loader, so the branch boots again. Configuration is now read from a `tex-bot-deployment.yaml` file, validated against the pydantic schema, and reached through typed nested attributes: what was `settings["STATISTICS_DAYS"]` returning `object` is now `settings.commands.stats.lookback_period` returning a `timedelta`. All 120 call sites across 15 files are migrated, and the whole project now type-checks cleanly for the first time on this branch. Deletes `config.py`, `config/_settings/`, `config/constants.py` and the hand-written `stubs/strictyaml/` type stubs, and drops the `strictyaml`, `aiopath` & `anyio` dependencies. The latter two were never imported by anything. Several values change type at the point of use, so those call sites are adapted rather than merely renamed: - Links are `HttpUrl`, so are converted where a `str` is required. - The bot token & the members-list authentication cookie are `SecretStr`, and are now unwrapped explicitly at the two places that genuinely need their value. - Reminder intervals are `timedelta` rather than a mapping of keyword arguments, so `tasks.loop()` is given `seconds=...` directly. - `membership-dependent-roles` defaults to being empty rather than absent, matching the behaviour of the loader being replaced, so that consumers need no null check before iterating it. Response messages stay in their own JSON file, loaded by `config/_messages.py`. They are a body of content rather than settings, so they are deliberately neither validated by the settings schema nor editable through the `/config` command. Logging is applied from the loaded settings by `config/_logging.py`, replacing handlers rather than adding to them, so that reloading cannot accumulate duplicates. Restores `ImproperlyConfiguredError`, which two existing database migrations import and therefore cannot be removed. Adds `tex-bot-deployment.example.yaml` documenting the new format, and ignores the real configuration file, which holds the bot token, along with the temporary file written beside it whilst it is being rewritten. --- .gitignore | 7 + cogs/add_users_to_threads_and_channels.py | 2 +- cogs/annual_handover_and_reset.py | 8 +- cogs/check_su_platform_authorisation.py | 8 +- cogs/induct.py | 13 +- cogs/invite_link.py | 6 +- cogs/make_member.py | 34 +- cogs/ping.py | 4 +- cogs/send_get_roles_reminders.py | 9 +- cogs/send_introduction_reminders.py | 16 +- cogs/startup.py | 27 +- cogs/stats/__init__.py | 30 +- cogs/stats/counts.py | 8 +- cogs/strike.py | 9 +- cogs/write_roles.py | 4 +- config.py | 1046 ----------------- config/__init__.py | 64 +- config/_logging.py | 115 ++ config/_messages.py | 148 +++ config/_schema.py | 16 +- config/_settings/__init__.py | 105 -- config/_settings/_yaml/__init__.py | 162 --- .../_yaml/custom_scalar_validators.py | 358 ------ config/constants.py | 507 -------- db/_settings.py | 2 +- exceptions/__init__.py | 1 + exceptions/config_changes.py | 47 +- main.py | 2 +- pyproject.toml | 4 - stubs/strictyaml/__init__.pyi | 49 - stubs/strictyaml/constants.pyi | 3 - stubs/strictyaml/exceptions.pyi | 4 - stubs/strictyaml/utils.pyi | 4 - stubs/strictyaml/yamllocation.pyi | 3 - tex-bot-deployment.example.yaml | 83 ++ utils/msl/memberships.py | 8 +- utils/tex_bot.py | 13 +- uv.lock | 70 -- 38 files changed, 571 insertions(+), 2428 deletions(-) delete mode 100644 config.py create mode 100644 config/_logging.py create mode 100644 config/_messages.py delete mode 100644 config/_settings/__init__.py delete mode 100644 config/_settings/_yaml/__init__.py delete mode 100644 config/_settings/_yaml/custom_scalar_validators.py delete mode 100644 config/constants.py delete mode 100644 stubs/strictyaml/__init__.pyi delete mode 100644 stubs/strictyaml/constants.pyi delete mode 100644 stubs/strictyaml/exceptions.pyi delete mode 100644 stubs/strictyaml/utils.pyi delete mode 100644 stubs/strictyaml/yamllocation.pyi create mode 100644 tex-bot-deployment.example.yaml diff --git a/.gitignore b/.gitignore index b0f10b587..591773d77 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,13 @@ pids .dmypy.json dmypy.json +# TeX-Bot deployment configuration (contains the bot token & other secrets) +tex-bot-deployment.yaml +tex-bot-deployment.*.yaml +!tex-bot-deployment.example.yaml +# NOTE: Written alongside the configuration file whilst it is being rewritten in place. +tex-bot-deployment.yaml.*.tmp + # Environments .env ._env diff --git a/cogs/add_users_to_threads_and_channels.py b/cogs/add_users_to_threads_and_channels.py index 0eb478965..6e5d708de 100644 --- a/cogs/add_users_to_threads_and_channels.py +++ b/cogs/add_users_to_threads_and_channels.py @@ -175,7 +175,7 @@ async def on_thread_create(self, thread: discord.Thread) -> None: thread.parent is None # noqa: CAR180 or thread.parent.category is None or "committee" not in thread.parent.category.name.lower() - or not settings["AUTO_ADD_COMMITTEE_TO_THREADS"] + or not settings.auto_add_committee_to_threads ): return diff --git a/cogs/annual_handover_and_reset.py b/cogs/annual_handover_and_reset.py index ffe8c7ea4..ea931e99d 100644 --- a/cogs/annual_handover_and_reset.py +++ b/cogs/annual_handover_and_reset.py @@ -208,12 +208,12 @@ async def annual_roles_reset(self, ctx: "TeXBotApplicationContext") -> None: membership_dependent_roles: Mapping[str, discord.Role] = { role.name: role for role in main_guild.roles - if role.name in settings["MEMBERSHIP_DEPENDENT_ROLES"] + if role.name in settings.community_group.membership_dependent_roles } - not_found_role_names: Collection[str] = settings[ - "MEMBERSHIP_DEPENDENT_ROLES" - ] - set(membership_dependent_roles.keys()) + not_found_role_names: Collection[str] = set( + settings.community_group.membership_dependent_roles + ) - set(membership_dependent_roles.keys()) if not_found_role_names: logger.warning( "Membership dependent roles %s were configured but could not be found.", diff --git a/cogs/check_su_platform_authorisation.py b/cogs/check_su_platform_authorisation.py index 43a237a55..3b8124333 100644 --- a/cogs/check_su_platform_authorisation.py +++ b/cogs/check_su_platform_authorisation.py @@ -78,7 +78,7 @@ async def get_su_platform_access_cookie_status(self) -> SUPlatformAccessCookieSt return SUPlatformAccessCookieStatus.INVALID organisation_admin_url: str = ( - f"{SU_PLATFORM_ORGANISATION_URL}/{settings['ORGANISATION_ID']}" + f"{SU_PLATFORM_ORGANISATION_URL}/{settings.community_group.msl.organisation_id}" ) response_html: str = await fetch_url_content_with_session(organisation_admin_url) @@ -211,7 +211,7 @@ class CheckSUPlatformAuthorisationTaskCog(CheckSUPlatformAuthorisationBaseCog): @override def __init__(self, bot: "TeXBot") -> None: """Start all task managers when this cog is initialised.""" - if settings["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"]: + if settings.community_group.msl.auto_cookie_checking.enabled: _ = self.su_platform_access_cookie_check_task.start() super().__init__(bot) @@ -225,7 +225,9 @@ def cog_unload(self) -> None: """ self.su_platform_access_cookie_check_task.cancel() - @tasks.loop(**settings["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"]) + @tasks.loop( + seconds=settings.community_group.msl.auto_cookie_checking.interval.total_seconds() + ) @capture_guild_does_not_exist_error async def su_platform_access_cookie_check_task(self) -> None: """ diff --git a/cogs/induct.py b/cogs/induct.py index e718fd7d9..744083d2a 100644 --- a/cogs/induct.py +++ b/cogs/induct.py @@ -7,7 +7,7 @@ import discord -from config import settings +from config import messages, settings from db.core.models import IntroductionReminderOptOutMember from exceptions import ( ApplicantRoleDoesNotExistError, @@ -112,11 +112,11 @@ async def on_member_update(self, before: discord.Member, after: discord.Member) messages_to_send.append( f"You can also get yourself an annual membership " f"to {self.bot.group_full_name} for only £5! " - f"Just head to {settings['PURCHASE_MEMBERSHIP_URL']}. " + f"Just head to {settings.community_group.links.purchase_membership}. " "You'll get awesome perks like a free T-shirt:shirt:, " "access to member only events:calendar_spiral: and a cool green name on " f"the {self.bot.group_short_name} Discord server:green_square:! " - f"Checkout all the perks at {settings['MEMBERSHIP_PERKS_URL']}" + f"Checkout all the perks at {settings.community_group.links.membership_perks}" ) try: @@ -141,7 +141,7 @@ async def get_random_welcome_message( self, induction_member: discord.User | discord.Member | None = None ) -> str: """Get & format a random welcome message.""" - random_welcome_message: str = random.choice(tuple(settings["WELCOME_MESSAGES"])) # noqa: S311 + random_welcome_message: str = random.choice(tuple(messages.welcome_messages)) # noqa: S311 if "" in random_welcome_message: if not induction_member: @@ -162,11 +162,12 @@ async def get_random_welcome_message( ) if "" in random_welcome_message: - if not settings["PURCHASE_MEMBERSHIP_URL"]: + if not settings.community_group.links.purchase_membership: return await self.get_random_welcome_message(induction_member) random_welcome_message = random_welcome_message.replace( - "", settings["PURCHASE_MEMBERSHIP_URL"] + "", + str(settings.community_group.links.purchase_membership), ) if "" in random_welcome_message: diff --git a/cogs/invite_link.py b/cogs/invite_link.py index 5c273d022..d0d0fa316 100644 --- a/cogs/invite_link.py +++ b/cogs/invite_link.py @@ -23,7 +23,11 @@ class InviteLinkCommandCog(TeXBotBaseCog): ) async def invite_link(self, ctx: "TeXBotApplicationContext") -> None: """Definition & callback response of the "invite_link" command.""" - discord_invite_url: str | None = settings["CUSTOM_DISCORD_INVITE_URL"] + discord_invite_url: str | None = ( + str(settings.community_group.links.custom_discord_invite_link) + if settings.community_group.links.custom_discord_invite_link + else None + ) if not discord_invite_url: invite_destination_channel: discord.TextChannel | None = discord.utils.get( diff --git a/cogs/make_member.py b/cogs/make_member.py index 6139b2099..6d885fc54 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -30,16 +30,16 @@ _GROUP_MEMBER_ID_ARGUMENT_DESCRIPTIVE_NAME: "Final[str]" = f"""{ "Student" if ( - settings["_GROUP_FULL_NAME"] + settings.community_group.full_name and ( - "computer science society" in settings["_GROUP_FULL_NAME"].lower() # noqa: CAR180 - or "css" in settings["_GROUP_FULL_NAME"].lower() - or "uob" in settings["_GROUP_FULL_NAME"].lower() - or "university of birmingham" in settings["_GROUP_FULL_NAME"].lower() - or "uob" in settings["_GROUP_FULL_NAME"].lower() + "computer science society" in settings.community_group.full_name.lower() # noqa: CAR180 + or "css" in settings.community_group.full_name.lower() + or "uob" in settings.community_group.full_name.lower() + or "university of birmingham" in settings.community_group.full_name.lower() + or "uob" in settings.community_group.full_name.lower() or ( - "bham" in settings["_GROUP_FULL_NAME"].lower() - and "uni" in settings["_GROUP_FULL_NAME"].lower() + "bham" in settings.community_group.full_name.lower() + and "uni" in settings.community_group.full_name.lower() ) ) ) @@ -67,16 +67,18 @@ class MakeMemberCommandCog(TeXBotBaseCog): f"""Your UoB Student { "UoB Student" if ( - settings["_GROUP_FULL_NAME"] + settings.community_group.full_name and ( - "computer science society" in settings["_GROUP_FULL_NAME"].lower() # noqa: CAR180 - or "css" in settings["_GROUP_FULL_NAME"].lower() - or "uob" in settings["_GROUP_FULL_NAME"].lower() - or "university of birmingham" in settings["_GROUP_FULL_NAME"].lower() - or "uob" in settings["_GROUP_FULL_NAME"].lower() + "computer science society" + in settings.community_group.full_name.lower() # noqa: CAR180 + or "css" in settings.community_group.full_name.lower() + or "uob" in settings.community_group.full_name.lower() + or "university of birmingham" + in settings.community_group.full_name.lower() + or "uob" in settings.community_group.full_name.lower() or ( - "bham" in settings["_GROUP_FULL_NAME"].lower() - and "uni" in settings["_GROUP_FULL_NAME"].lower() + "bham" in settings.community_group.full_name.lower() + and "uni" in settings.community_group.full_name.lower() ) ) ) diff --git a/cogs/ping.py b/cogs/ping.py index fa3d71378..ce372173b 100644 --- a/cogs/ping.py +++ b/cogs/ping.py @@ -26,8 +26,8 @@ async def ping(self, ctx: "TeXBotApplicationContext") -> None: random.choices( # noqa: S311 ["Pong!", "`64 bytes from TeX-Bot: icmp_seq=1 ttl=63 time=0.01 ms`"], weights=( - 100 - settings["PING_COMMAND_EASTER_EGG_PROBABILITY"], - settings["PING_COMMAND_EASTER_EGG_PROBABILITY"], + 100 - settings.commands.ping.easter_egg_probability, + settings.commands.ping.easter_egg_probability, ), )[0], ephemeral=True, diff --git a/cogs/send_get_roles_reminders.py b/cogs/send_get_roles_reminders.py index 3add49801..0f29f1bc5 100644 --- a/cogs/send_get_roles_reminders.py +++ b/cogs/send_get_roles_reminders.py @@ -38,7 +38,7 @@ class SendGetRolesRemindersTaskCog(TeXBotBaseCog): @override def __init__(self, bot: "TeXBot") -> None: """Start all task managers when this cog is initialised.""" - if settings["SEND_GET_ROLES_REMINDERS"]: + if settings.reminders.send_get_roles_reminders.enabled: _ = self.send_get_roles_reminders.start() super().__init__(bot) @@ -52,7 +52,7 @@ def cog_unload(self) -> None: """ self.send_get_roles_reminders.cancel() - @tasks.loop(**settings["ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL"]) + @tasks.loop(seconds=settings.reminders.send_get_roles_reminders.interval.total_seconds()) @functools.partial( ErrorCaptureDecorators.capture_error_and_close, error_type=GuestRoleDoesNotExistError, @@ -147,7 +147,10 @@ async def send_get_roles_reminders(self) -> None: time_since_role_received: datetime.timedelta = ( discord.utils.utcnow() - guest_role_received_time ) - if time_since_role_received <= settings["SEND_GET_ROLES_REMINDERS_DELAY"]: + if ( + time_since_role_received + <= settings.reminders.send_get_roles_reminders.delay + ): continue if ( diff --git a/cogs/send_introduction_reminders.py b/cogs/send_introduction_reminders.py index 67480680d..5384d33c4 100644 --- a/cogs/send_introduction_reminders.py +++ b/cogs/send_introduction_reminders.py @@ -44,8 +44,8 @@ class SendIntroductionRemindersTaskCog(TeXBotBaseCog): @override def __init__(self, bot: "TeXBot") -> None: """Start all task managers when this cog is initialised.""" - if settings["SEND_INTRODUCTION_REMINDERS"]: - if settings["SEND_INTRODUCTION_REMINDERS"] == "interval": + if settings.reminders.send_introduction_reminders.enabled: + if settings.reminders.send_introduction_reminders.enabled == "interval": SentOneOffIntroductionReminderMember.objects.all().delete() _ = self.send_introduction_reminders.start() @@ -66,7 +66,9 @@ async def on_ready(self) -> None: """Add OptOutIntroductionRemindersView to the bot's list of permanent views.""" self.bot.add_view(self.OptOutIntroductionRemindersView(self.bot)) - @tasks.loop(**settings["SEND_INTRODUCTION_REMINDERS_INTERVAL"]) + @tasks.loop( + seconds=settings.reminders.send_introduction_reminders.interval.total_seconds() + ) @functools.partial( ErrorCaptureDecorators.capture_error_and_close, error_type=GuestRoleDoesNotExistError, @@ -104,7 +106,7 @@ async def send_introduction_reminders(self) -> None: continue member_needs_one_off_reminder: bool = ( - settings["SEND_INTRODUCTION_REMINDERS"] == "once" + settings.reminders.send_introduction_reminders.enabled == "once" and not await ( SentOneOffIntroductionReminderMember.objects.filter( discord_member__discord_id=member.id, @@ -112,11 +114,11 @@ async def send_introduction_reminders(self) -> None: ).aexists() ) member_needs_recurring_reminder: bool = ( - settings["SEND_INTRODUCTION_REMINDERS"] == "interval" + settings.reminders.send_introduction_reminders.enabled == "interval" ) member_recently_joined: bool = ( discord.utils.utcnow() - member.joined_at - ) <= settings["SEND_INTRODUCTION_REMINDERS_DELAY"] + ) <= settings.reminders.send_introduction_reminders.delay member_opted_out_from_reminders: bool = await ( IntroductionReminderOptOutMember.objects.filter( discord_member__discord_id=member.id, @@ -166,7 +168,7 @@ async def send_introduction_reminders(self) -> None: ), view=( self.OptOutIntroductionRemindersView(self.bot) - if settings["SEND_INTRODUCTION_REMINDERS"] == "interval" + if settings.reminders.send_introduction_reminders.enabled == "interval" else None # type: ignore[arg-type] ), ) diff --git a/cogs/startup.py b/cogs/startup.py index c19028d44..09b5118d3 100644 --- a/cogs/startup.py +++ b/cogs/startup.py @@ -42,10 +42,10 @@ async def on_ready(self) -> None: Shortcut accessors should only be populated once TeX-Bot is ready to make API requests. """ - if settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"]: + if settings.logging.discord_channel is not None: discord_logging_handler: logging.Handler = DiscordHandler( service_name=self.bot.user.name if self.bot.user else "TeX-Bot", - webhook_url=settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"], + webhook_url=str(settings.logging.discord_channel.webhook_url), avatar_url=( self.bot.user.avatar.url if self.bot.user and self.bot.user.avatar @@ -68,7 +68,7 @@ async def on_ready(self) -> None: try: main_guild: discord.Guild | None = self.bot.main_guild except GuildDoesNotExistError: - main_guild = self.bot.get_guild(settings["_DISCORD_MAIN_GUILD_ID"]) + main_guild = self.bot.get_guild(settings.discord.main_guild_id) if main_guild: self.bot.set_main_guild(main_guild) @@ -77,19 +77,17 @@ async def on_ready(self) -> None: logger.info( "Invite URL: %s", utils.generate_invite_url( - self.bot.application_id, settings["_DISCORD_MAIN_GUILD_ID"] + self.bot.application_id, settings.discord.main_guild_id ), ) - logger.critical( - GuildDoesNotExistError(guild_id=settings["_DISCORD_MAIN_GUILD_ID"]) - ) + logger.critical(GuildDoesNotExistError(guild_id=settings.discord.main_guild_id)) await self.bot.close() if self.bot.application_id: logger.debug( "Invite URL: %s", utils.generate_invite_url( - self.bot.application_id, settings["_DISCORD_MAIN_GUILD_ID"] + self.bot.application_id, settings.discord.main_guild_id ), ) @@ -119,11 +117,11 @@ async def on_ready(self) -> None: msl_membership_error, ) - if settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"] != "DM": + if settings.commands.strike.performed_manually_warning_location != "DM": manual_moderation_warning_message_location_exists: bool = bool( discord.utils.get( main_guild.text_channels, - name=settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"], + name=settings.commands.strike.performed_manually_warning_location, ) ) if not manual_moderation_warning_message_location_exists: @@ -132,11 +130,12 @@ async def on_ready(self) -> None: "The channel %s does not exist, so cannot be used as the location " "for sending manual-moderation warning messages" ), - repr(settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"]), + repr(settings.commands.strike.performed_manually_warning_location), + ) + manual_moderation_warning_message_location_similar_to_dm: bool = ( + settings.commands.strike.performed_manually_warning_location.lower() + in ("dm", "dms") ) - manual_moderation_warning_message_location_similar_to_dm: bool = settings[ - "STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION" - ].lower() in ("dm", "dms") if manual_moderation_warning_message_location_similar_to_dm: logger.info( ( diff --git a/cogs/stats/__init__.py b/cogs/stats/__init__.py index 8b331c222..6cb2d0027 100644 --- a/cogs/stats/__init__.py +++ b/cogs/stats/__init__.py @@ -29,8 +29,8 @@ class StatsCommandsCog(TeXBotBaseCog): _DISCORD_SERVER_NAME: "Final[str]" = f"""{ "the " if ( - settings["_GROUP_SHORT_NAME"] is not None - and (settings["_GROUP_SHORT_NAME"]) + settings.community_group.short_name is not None + and (settings.community_group.short_name) .replace("the", "") .replace("THE", "") .replace("The", "") @@ -39,15 +39,15 @@ class StatsCommandsCog(TeXBotBaseCog): else "" }{ ( - (settings["_GROUP_SHORT_NAME"]) + (settings.community_group.short_name) .replace("the", "") .replace("THE", "") .replace("The", "") .strip() ) if ( - settings["_GROUP_SHORT_NAME"] is not None - and (settings["_GROUP_SHORT_NAME"]) + settings.community_group.short_name is not None + and (settings.community_group.short_name) .replace("the", "") .replace("THE", "") .replace("The", "") @@ -125,7 +125,9 @@ async def channel_stats( x_label="Role Name", y_label=( f"""Number of Messages Sent (in the past { - amount_of_time_formatter(settings["STATISTICS_DAYS"].days, "day") + amount_of_time_formatter( + settings.commands.stats.lookback_period.days, "day" + ) })""" ), title=f"Most Active Roles in #{channel.name}", @@ -183,7 +185,9 @@ async def server_stats(self, ctx: "TeXBotApplicationContext") -> None: x_label="Role Name", y_label=( f"""Number of Messages Sent (in the past { - amount_of_time_formatter(settings["STATISTICS_DAYS"].days, "day") + amount_of_time_formatter( + settings.commands.stats.lookback_period.days, "day" + ) })""" ), title=( @@ -205,7 +209,9 @@ async def server_stats(self, ctx: "TeXBotApplicationContext") -> None: x_label="Channel Name", y_label=( f"""Number of Messages Sent (in the past { - amount_of_time_formatter(settings["STATISTICS_DAYS"].days, "day") + amount_of_time_formatter( + settings.commands.stats.lookback_period.days, "day" + ) })""" ), title=( @@ -263,7 +269,7 @@ async def user_stats(self, ctx: "TeXBotApplicationContext") -> None: message_counts[f"#{channel.name}"] = 0 message_history_period: AsyncIterable[discord.Message] = channel.history( - after=discord.utils.utcnow() - settings["STATISTICS_DAYS"] + after=discord.utils.utcnow() - settings.commands.stats.lookback_period ) message: discord.Message async for message in message_history_period: @@ -284,7 +290,9 @@ async def user_stats(self, ctx: "TeXBotApplicationContext") -> None: x_label="Channel Name", y_label=( f"""Number of Messages Sent (in the past { - amount_of_time_formatter(settings["STATISTICS_DAYS"].days, "day") + amount_of_time_formatter( + settings.commands.stats.lookback_period.days, "day" + ) })""" ), title=( @@ -321,7 +329,7 @@ async def left_member_stats(self, ctx: "TeXBotApplicationContext") -> None: } role_name: str - for role_name in settings["STATISTICS_ROLES"]: + for role_name in settings.commands.stats.displayed_roles: if discord.utils.get(main_guild.roles, name=role_name): left_member_counts[f"@{role_name}"] = 0 diff --git a/cogs/stats/counts.py b/cogs/stats/counts.py index b8f38624b..88ecd4b1f 100644 --- a/cogs/stats/counts.py +++ b/cogs/stats/counts.py @@ -24,12 +24,12 @@ async def get_channel_message_counts(channel: discord.TextChannel) -> "Mapping[s message_counts: dict[str, int] = {"Total": 0} role_name: str - for role_name in settings["STATISTICS_ROLES"]: + for role_name in settings.commands.stats.displayed_roles: if discord.utils.get(channel.guild.roles, name=role_name): message_counts[f"@{role_name}"] = 0 message_history_period: AsyncIterable[discord.Message] = channel.history( - after=discord.utils.utcnow() - settings["STATISTICS_DAYS"] + after=discord.utils.utcnow() - settings.commands.stats.lookback_period ) message: discord.Message async for message in message_history_period: @@ -76,7 +76,7 @@ async def get_server_message_counts( message_counts: dict[str, dict[str, int]] = {"roles": {"Total": 0}, "channels": {}} role_name: str - for role_name in settings["STATISTICS_ROLES"]: + for role_name in settings.commands.stats.displayed_roles: if discord.utils.get(guild.roles, name=role_name): message_counts["roles"][f"@{role_name}"] = 0 @@ -91,7 +91,7 @@ async def get_server_message_counts( message_counts["channels"][f"#{channel.name}"] = 0 message_history_period: AsyncIterable[discord.Message] = channel.history( - after=discord.utils.utcnow() - settings["STATISTICS_DAYS"] + after=discord.utils.utcnow() - settings.commands.stats.lookback_period ) message: discord.Message async for message in message_history_period: diff --git a/cogs/strike.py b/cogs/strike.py index 46fc40ec2..9ea960c92 100644 --- a/cogs/strike.py +++ b/cogs/strike.py @@ -241,7 +241,8 @@ async def _send_strike_user_message( "To find what moderation action corresponds to which strike level, " "you can view " f"the {self.bot.group_short_name} Discord server moderation document " - f"[here](<{settings.MODERATION_DOCUMENT_URL}>)\nPlease ensure you have read " + f"[here](<{settings.community_group.links.moderation_policy}>)\n" + "Please ensure you have read " f"the rules in {await self.bot.get_mention_string(self.bot.rules_channel)} so " "that your future behaviour adheres to them." f"{ @@ -442,7 +443,7 @@ async def get_confirmation_message_channel( This is based upon the STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION config setting value. """ - if settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"] == "DM": + if settings.commands.strike.performed_manually_warning_location == "DM": if user.bot: fetch_log_channel_error: RuntimeError try: @@ -477,12 +478,12 @@ async def get_confirmation_message_channel( guild_confirmation_message_channel: discord.TextChannel | None = discord.utils.get( self.bot.main_guild.text_channels, - name=settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"], + name=settings.commands.strike.performed_manually_warning_location, ) if not guild_confirmation_message_channel: CHANNEL_DOES_NOT_EXIST_MESSAGE: Final[str] = ( "The channel " - f"""{settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"]!r} """ + f"""{settings.commands.strike.performed_manually_warning_location!r} """ "does not exist, so cannot be used as the location " "for sending manual-moderation warning messages" ) diff --git a/cogs/write_roles.py b/cogs/write_roles.py index adc41bbd8..950296ef7 100644 --- a/cogs/write_roles.py +++ b/cogs/write_roles.py @@ -4,7 +4,7 @@ import discord -from config import settings +from config import messages from utils import CommandChecks, TeXBotBaseCog if TYPE_CHECKING: @@ -34,7 +34,7 @@ async def write_roles(self, ctx: "TeXBotApplicationContext") -> None: roles_channel: discord.TextChannel = await self.bot.roles_channel roles_message: str - for roles_message in settings["ROLES_MESSAGES"]: + for roles_message in messages.roles_messages: await roles_channel.send( roles_message.replace("", self.bot.group_short_name) ) diff --git a/config.py b/config.py deleted file mode 100644 index 572fdea99..000000000 --- a/config.py +++ /dev/null @@ -1,1046 +0,0 @@ -""" -Contains settings values and import and setup functions. - -Settings values are imported from the .env file or the current environment variables. -These values are used to configure the functionality of the bot at run-time. -""" - -import abc -import datetime -import functools -import importlib -import json -import logging -import os -import re -from collections.abc import Iterable, Mapping -from pathlib import Path -from typing import TYPE_CHECKING, final - -import dotenv -import validators -from discord_logging.handler import DiscordHandler - -from exceptions import ( - ImproperlyConfiguredError, - MessagesJSONFileMissingKeyError, - MessagesJSONFileValueError, -) - -if TYPE_CHECKING: - from collections.abc import Sequence - from collections.abc import Set as AbstractSet - from logging import Logger - from typing import IO, Any, ClassVar, Final, LiteralString - -__all__: "Sequence[str]" = ( - "DEFAULT_STATISTICS_ROLES", - "FALSE_VALUES", - "LOG_LEVEL_CHOICES", - "TRUE_VALUES", - "VALID_SEND_INTRODUCTION_REMINDERS_VALUES", - "run_setup", - "settings", -) - - -PROJECT_ROOT: "Final[Path]" = Path(__file__).parent.resolve() - -TRUE_VALUES: "Final[AbstractSet[LiteralString]]" = {"true", "1", "t", "y", "yes", "on"} -FALSE_VALUES: "Final[AbstractSet[LiteralString]]" = {"false", "0", "f", "n", "no", "off"} -VALID_SEND_INTRODUCTION_REMINDERS_VALUES: "Final[AbstractSet[LiteralString]]" = ( - {"once", "interval"} | TRUE_VALUES | FALSE_VALUES -) -DEFAULT_STATISTICS_ROLES: "Final[AbstractSet[LiteralString]]" = { - "Committee", - "Committee-Elect", - "Student Rep", - "Member", - "Guest", - "Server Booster", - "Foundation Year", - "First Year", - "Second Year", - "Final Year", - "Year In Industry", - "Year Abroad", - "PGT", - "PGR", - "Alumnus/Alumna", - "Postdoc", - "Quiz Victor", -} -LOG_LEVEL_CHOICES: "Final[Sequence[LiteralString]]" = ( - "DEBUG", - "INFO", - "WARNING", - "ERROR", - "CRITICAL", -) - -logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -discord_logger: "Final[Logger]" = logging.getLogger("discord") - - -class Settings(abc.ABC): - """ - Settings class that provides access to all settings values. - - Settings values can be accessed via key (like a dictionary) or via class attribute. - """ - - _is_env_variables_setup: "ClassVar[bool]" - _settings: "ClassVar[dict[str, object]]" - - @classmethod - def get_invalid_settings_key_message(cls, item: str) -> str: - """Return the message to state that the given settings key is invalid.""" - return f"{item!r} is not a valid settings key." - - def __getattr__(self, item: str) -> "Any": # type: ignore[explicit-any] # noqa: ANN401 - """Retrieve settings value by attribute lookup.""" - MISSING_ATTRIBUTE_MESSAGE: Final[str] = ( - f"{type(self).__name__!r} object has no attribute {item!r}" - ) - - if ( - "_pytest" in item or item in ("__bases__", "__test__") - ): # NOTE: Overriding __getattr__() leads to many edge-case issues where external libraries will attempt to call getattr() with peculiar values - raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) - - if not self._is_env_variables_setup: - self._setup_env_variables() - - if item in self._settings: - return self._settings[item] - - if re.fullmatch(pattern=r"\A[A-Z](?:[A-Z_]*[A-Z])?\Z", string=item): - INVALID_SETTINGS_KEY_MESSAGE: Final[str] = self.get_invalid_settings_key_message( - item - ) - raise AttributeError(INVALID_SETTINGS_KEY_MESSAGE) - - raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) - - def __getitem__(self, item: str) -> "Any": # type: ignore[explicit-any] # noqa: ANN401 - """Retrieve settings value by key lookup.""" - attribute_not_exist_error: AttributeError - try: - return getattr(self, item) - except AttributeError as attribute_not_exist_error: - key_error_message: str = item - - if self.get_invalid_settings_key_message(item) in str(attribute_not_exist_error): - key_error_message = str(attribute_not_exist_error) - - raise KeyError(key_error_message) from None - - @staticmethod - def _setup_console_logging() -> None: - raw_console_log_level: str = ( - os.getenv("CONSOLE_LOG_LEVEL", default="INFO").upper().strip() - ) - - if raw_console_log_level not in LOG_LEVEL_CHOICES: - INVALID_LOG_LEVEL_MESSAGE: Final[str] = f"CONSOLE_LOG_LEVEL must be one of { - ','.join( - f'{log_level_choice!r}' for log_level_choice in LOG_LEVEL_CHOICES[:-1] - ) - } or {LOG_LEVEL_CHOICES[-1]!r}." - raise ImproperlyConfiguredError(INVALID_LOG_LEVEL_MESSAGE) - - logger.setLevel(getattr(logging, raw_console_log_level)) - - console_logging_handler: logging.Handler = logging.StreamHandler() - console_logging_handler.setFormatter( - logging.Formatter("{asctime} | {name} | {levelname:^8} - {message}", style="{") - ) - - logger.addHandler(console_logging_handler) - logger.propagate = False - - @staticmethod - def _setup_discord_log_level() -> None: - raw_discord_log_level: str = os.getenv("DISCORD_LOG_LEVEL", default="").upper().strip() - - if not raw_discord_log_level: - logger.debug("DISCORD_LOG_LEVEL is not set, skipping Discord logging setup.") - return - - if raw_discord_log_level not in LOG_LEVEL_CHOICES: - INVALID_LOG_LEVEL_MESSAGE: Final[str] = ( - "DISCORD_LOG_LEVEL must be one of " - f"{ - ','.join( - f'{log_level_choice!r}' for log_level_choice in LOG_LEVEL_CHOICES[:-1] - ) - } or {LOG_LEVEL_CHOICES[-1]!r}" - ) - raise ImproperlyConfiguredError(INVALID_LOG_LEVEL_MESSAGE) - - discord_logger.setLevel(getattr(logging, raw_discord_log_level)) - - discord_log_handler: logging.Handler = logging.FileHandler( - filename="discord.log", encoding="utf-8", mode="a" - ) - discord_log_handler.setFormatter( - logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s") - ) - - discord_logger.addHandler(discord_log_handler) - discord_logger.propagate = False - - @classmethod - def _setup_discord_bot_token(cls) -> None: - raw_discord_bot_token: str = os.getenv("DISCORD_BOT_TOKEN", default="").strip() - - if not raw_discord_bot_token or not re.fullmatch( - pattern=r"\A([A-Za-z0-9_-]{24,26})\.([A-Za-z0-9_-]{6})\.([A-Za-z0-9_-]{27,38})\Z", - string=raw_discord_bot_token, - ): - INVALID_DISCORD_BOT_TOKEN_MESSAGE: Final[str] = ( - "DISCORD_BOT_TOKEN must be set to a valid Discord bot token " # noqa: S105 - "(see https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts)." - ) - raise ImproperlyConfiguredError(INVALID_DISCORD_BOT_TOKEN_MESSAGE) - - cls._settings["DISCORD_BOT_TOKEN"] = raw_discord_bot_token - - @classmethod - def _setup_discord_log_channel_webhook(cls) -> "Logger": - raw_discord_log_channel_webhook_url: str = os.getenv( - "DISCORD_LOG_CHANNEL_WEBHOOK_URL", default="" - ).strip() - - if not raw_discord_log_channel_webhook_url: - cls._settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"] = None - return logging.getLogger("_temp_webhook_config") - - 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 " - "that points to a discord channel where logs should be displayed." - ) - raise ImproperlyConfiguredError(INVALID_DISCORD_LOG_CHANNEL_WEBHOOK_URL_MESSAGE) - - webhook_config_logger: Logger = logging.getLogger("_temp_webhook_config") - - discord_logging_handler: logging.Handler = DiscordHandler( - service_name="TeX-Bot", webhook_url=raw_discord_log_channel_webhook_url - ) - - discord_logging_handler.setLevel(logging.WARNING) - - discord_logging_handler.setFormatter( - logging.Formatter("{levelname} | {message}", style="{") - ) - - webhook_config_logger.addHandler(discord_logging_handler) - - cls._settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"] = raw_discord_log_channel_webhook_url - - return webhook_config_logger - - @classmethod - def _setup_discord_guild_id(cls) -> None: - raw_discord_guild_id: str = os.getenv("DISCORD_GUILD_ID", default="").strip() - - if not raw_discord_guild_id or not re.fullmatch( - pattern=r"\A\d{17,20}\Z", string=raw_discord_guild_id - ): - INVALID_DISCORD_GUILD_ID_MESSAGE: Final[str] = ( - "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 ImproperlyConfiguredError(INVALID_DISCORD_GUILD_ID_MESSAGE) - - cls._settings["_DISCORD_MAIN_GUILD_ID"] = int(raw_discord_guild_id) - - @classmethod - def _setup_group_full_name(cls) -> None: - raw_group_full_name: str = os.getenv("GROUP_NAME", default="").strip() - - if not raw_group_full_name: - cls._settings["_GROUP_FULL_NAME"] = None - return - - if not re.fullmatch( - pattern=r"\A[A-Za-z0-9 '&!?:,.#%\"-]+\Z", string=raw_group_full_name - ): - INVALID_GROUP_FULL_NAME: Final[str] = ( - "GROUP_NAME must not contain any invalid characters." - ) - raise ImproperlyConfiguredError(INVALID_GROUP_FULL_NAME) - - cls._settings["_GROUP_FULL_NAME"] = raw_group_full_name - - @classmethod - def _setup_group_short_name(cls) -> None: - raw_group_short_name: str = os.getenv("GROUP_SHORT_NAME", default="").strip() - - if not raw_group_short_name: - cls._settings["_GROUP_SHORT_NAME"] = None - return - - if not re.fullmatch( - pattern=r"\A[A-Za-z0-9'&!?:,.#%\"-]+\Z", string=raw_group_short_name - ): - INVALID_GROUP_SHORT_NAME: Final[str] = ( - "GROUP_SHORT_NAME must not contain any invalid characters." - ) - raise ImproperlyConfiguredError(INVALID_GROUP_SHORT_NAME) - - cls._settings["_GROUP_SHORT_NAME"] = raw_group_short_name - - @classmethod - def _setup_purchase_membership_url(cls) -> None: - 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 raw_purchase_membership_url.startswith("https://"): - if "://" in raw_purchase_membership_url: - INVALID_PURCHASE_MEMBERSHIP_URL_PROTOCOL_MESSAGE: Final[str] = ( - "Only HTTPS is supported as a protocol for PURCHASE_MEMBERSHIP_URL." - ) - raise ImproperlyConfiguredError( - INVALID_PURCHASE_MEMBERSHIP_URL_PROTOCOL_MESSAGE - ) - - raw_purchase_membership_url = "https://" + raw_purchase_membership_url - logger.warning( - "PURCHASE_MEMBERSHIP_URL was missing a URL protocol. " - "Please ensure all URLs are valid HTTPS URLs." - ) - - if not validators.url(raw_purchase_membership_url): - INVALID_PURCHASE_MEMBERSHIP_URL_MESSAGE: Final[str] = ( - "PURCHASE_MEMBERSHIP_URL must be a valid URL." - ) - raise ImproperlyConfiguredError(INVALID_PURCHASE_MEMBERSHIP_URL_MESSAGE) - - cls._settings["PURCHASE_MEMBERSHIP_URL"] = raw_purchase_membership_url - - @classmethod - def _setup_membership_perks_url(cls) -> None: - 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 raw_membership_perks_url.startswith("https://"): - if "://" in raw_membership_perks_url: - INVALID_MEMBERSHIP_PERKS_URL_PROTOCOL_MESSAGE: Final[str] = ( - "Only HTTPS is supported as a protocol for MEMBERSHIP_PERKS_URL." - ) - raise ImproperlyConfiguredError(INVALID_MEMBERSHIP_PERKS_URL_PROTOCOL_MESSAGE) - - raw_membership_perks_url = "https://" + raw_membership_perks_url - logger.warning( - "MEMBERSHIP_PERKS_URL was missing a URL protocol. " - "Please ensure all URLs are valid HTTPS URLs." - ) - - if not validators.url(raw_membership_perks_url): - INVALID_MEMBERSHIP_PERKS_URL_MESSAGE: Final[str] = ( - "MEMBERSHIP_PERKS_URL must be a valid URL." - ) - raise ImproperlyConfiguredError(INVALID_MEMBERSHIP_PERKS_URL_MESSAGE) - - cls._settings["MEMBERSHIP_PERKS_URL"] = raw_membership_perks_url - - @classmethod - def _setup_custom_discord_invite_url(cls) -> None: - 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 raw_custom_discord_invite_url.startswith("https://"): - if "://" in raw_custom_discord_invite_url: - INVALID_CUSTOM_DISCORD_INVITE_URL_PROTOCOL_MESSAGE: Final[str] = ( - "Only HTTPS is supported as a protocol for CUSTOM_DISCORD_INVITE_URL." - ) - raise ImproperlyConfiguredError( - INVALID_CUSTOM_DISCORD_INVITE_URL_PROTOCOL_MESSAGE - ) - - raw_custom_discord_invite_url = "https://" + raw_custom_discord_invite_url - logger.warning( - "CUSTOM_DISCORD_INVITE_URL was missing a URL protocol. " - "Please ensure all URLs are valid HTTPS URLs." - ) - - 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." - ) - raise ImproperlyConfiguredError(INVALID_CUSTOM_DISCORD_INVITE_URL_MESSAGE) - - cls._settings["CUSTOM_DISCORD_INVITE_URL"] = raw_custom_discord_invite_url - - @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() - - if not raw_ping_command_easter_egg_probability_string: - cls._settings["PING_COMMAND_EASTER_EGG_PROBABILITY"] = 1 - return - - INVALID_PING_COMMAND_EASTER_EGG_PROBABILITY_MESSAGE: Final[str] = ( - "PING_COMMAND_EASTER_EGG_PROBABILITY must be a float between & including 0 to 1." - ) - - e: ValueError - try: - raw_ping_command_easter_egg_probability: float = 100 * float( - raw_ping_command_easter_egg_probability_string - ) - except ValueError as e: - raise ( - ImproperlyConfiguredError(INVALID_PING_COMMAND_EASTER_EGG_PROBABILITY_MESSAGE) - ) from e - - if not 0 <= raw_ping_command_easter_egg_probability <= 100: - raise ImproperlyConfiguredError( - INVALID_PING_COMMAND_EASTER_EGG_PROBABILITY_MESSAGE - ) - - cls._settings["PING_COMMAND_EASTER_EGG_PROBABILITY"] = ( - raw_ping_command_easter_egg_probability - ) - - @classmethod - @functools.lru_cache(maxsize=5) - def _get_messages_dict(cls, raw_messages_file_path: str | None) -> Mapping[str, object]: - JSON_DECODING_ERROR_MESSAGE: Final[str] = ( - "Messages JSON file must contain a JSON string that can be decoded " - "into a Python dict object." - ) - - messages_file_path: Path = ( - Path(raw_messages_file_path.strip()) - if raw_messages_file_path - else PROJECT_ROOT / Path("messages.json") - ) - - if not messages_file_path.is_file(): - MESSAGES_FILE_PATH_DOES_NOT_EXIST_MESSAGE: Final[str] = ( - "MESSAGES_FILE_PATH must be a path to a file that exists." - ) - raise ImproperlyConfiguredError(MESSAGES_FILE_PATH_DOES_NOT_EXIST_MESSAGE) - - messages_file: IO[str] - with messages_file_path.open(encoding="utf8") as messages_file: - e: json.JSONDecodeError - try: - messages_dict: object = json.load(messages_file) - except json.JSONDecodeError as e: - raise ImproperlyConfiguredError(JSON_DECODING_ERROR_MESSAGE) from e - - if not isinstance(messages_dict, Mapping): - raise ImproperlyConfiguredError(JSON_DECODING_ERROR_MESSAGE) - - return messages_dict - - @classmethod - def _setup_welcome_messages(cls) -> None: - messages_dict: Mapping[str, object] = cls._get_messages_dict( - os.getenv("MESSAGES_FILE_PATH") - ) - - if "welcome_messages" not in messages_dict: - raise MessagesJSONFileMissingKeyError(missing_key="welcome_messages") - - WELCOME_MESSAGES_KEY_IS_VALID: Final[bool] = bool( - isinstance(messages_dict["welcome_messages"], Iterable) - and messages_dict["welcome_messages"] - ) - if not WELCOME_MESSAGES_KEY_IS_VALID: - raise MessagesJSONFileValueError( - dict_key="welcome_messages", invalid_value=messages_dict["welcome_messages"] - ) - - cls._settings["WELCOME_MESSAGES"] = set(messages_dict["welcome_messages"]) # type: ignore[call-overload] - - @classmethod - def _setup_roles_messages(cls) -> None: - messages_dict: Mapping[str, object] = cls._get_messages_dict( - os.getenv("MESSAGES_FILE_PATH") - ) - - if "roles_messages" not in messages_dict: - raise MessagesJSONFileMissingKeyError(missing_key="roles_messages") - - ROLES_MESSAGES_KEY_IS_VALID: Final[bool] = isinstance( - messages_dict["roles_messages"], Iterable - ) and bool(messages_dict["roles_messages"]) - if not ROLES_MESSAGES_KEY_IS_VALID: - raise MessagesJSONFileValueError( - dict_key="roles_messages", invalid_value=messages_dict["roles_messages"] - ) - - cls._settings["ROLES_MESSAGES"] = set(messages_dict["roles_messages"]) # type: ignore[call-overload] - - @classmethod - def _setup_organisation_id(cls) -> None: - raw_organisation_id: str = os.getenv("ORGANISATION_ID", default="").strip() - - if not raw_organisation_id or not re.fullmatch( - pattern=r"\A\d{4,5}\Z", string=raw_organisation_id - ): - INVALID_ORGANISATION_ID_MESSAGE: Final[str] = ( - "ORGANISATION_ID must be an integer 4 to 5 digits long." - ) - raise ImproperlyConfiguredError(INVALID_ORGANISATION_ID_MESSAGE) - - cls._settings["ORGANISATION_ID"] = raw_organisation_id - - @classmethod - def _setup_su_platform_access_cookie(cls) -> None: - raw_su_platform_access_cookie: str = os.getenv( - "SU_PLATFORM_ACCESS_COOKIE", - default="", - ).strip() - - if not raw_su_platform_access_cookie or not re.fullmatch( - pattern=r"\A[\w-]{512,1024}\Z", string=raw_su_platform_access_cookie - ): - INVALID_SU_PLATFORM_ACCESS_COOKIE_MESSAGE: Final[str] = ( - "SU_PLATFORM_ACCESS_COOKIE must be a valid .AspNet.SharedCookie cookie." - ) - raise ImproperlyConfiguredError(INVALID_SU_PLATFORM_ACCESS_COOKIE_MESSAGE) - - cls._settings["SU_PLATFORM_ACCESS_COOKIE"] = raw_su_platform_access_cookie - - @classmethod - def _setup_auto_su_platform_access_cookie_checking(cls) -> None: - raw_auto_auth_session_cookie_checking: str = ( - os.getenv("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING", default="False") - .lower() - .strip() - ) - - if raw_auto_auth_session_cookie_checking not in TRUE_VALUES | FALSE_VALUES: - INVALID_AUTO_AUTH_CHECKING_MESSAGE: Final[str] = ( - "AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING must be a boolean value." - ) - raise ImproperlyConfiguredError(INVALID_AUTO_AUTH_CHECKING_MESSAGE) - - cls._settings["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"] = ( - raw_auto_auth_session_cookie_checking in TRUE_VALUES - ) - - @classmethod - def _setup_auto_su_platform_access_cookie_checking_interval(cls) -> None: - if "AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING" not in cls._settings: - INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( - "Invalid setup order: AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING must be set up " - "before AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL can be set up." - ) - raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) - - if not cls._settings["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"]: - cls._settings["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"] = {"hours": 24} - return - - raw_auto_su_platform_access_cookie_checking_interval: re.Match[str] | None = ( - re.fullmatch( - pattern=r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?\Z", - string=( - os.getenv( - "AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL", default="24h" - ) - .strip() - .lower() - .replace(" ", "") - ), - ) - ) - - if not raw_auto_su_platform_access_cookie_checking_interval: - INVALID_AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL_MESSAGE: Final[str] = ( - "AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL must contain the delay " - "in any combination of seconds, minutes or hours." - ) - logger.debug(raw_auto_su_platform_access_cookie_checking_interval) - raise ImproperlyConfiguredError( - INVALID_AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL_MESSAGE - ) - - raw_timedelta_auto_su_platform_access_cookie_checking_interval: Mapping[str, float] = { - key: float(stripped_value) - for key, value in ( - raw_auto_su_platform_access_cookie_checking_interval.groupdict().items() - ) - if value and (stripped_value := value.strip()) - } - - if ( - datetime.timedelta( - **raw_timedelta_auto_su_platform_access_cookie_checking_interval - ).total_seconds() - <= 3 - ): - TOO_SMALL_AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL_MESSAGE: Final[str] = ( - "AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL " - "must be greater than 3 seconds." - ) - raise ImproperlyConfiguredError( - TOO_SMALL_AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL_MESSAGE, - ) - - cls._settings["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"] = ( - raw_timedelta_auto_su_platform_access_cookie_checking_interval - ) - - @classmethod - def _setup_send_introduction_reminders(cls) -> None: - raw_send_introduction_reminders: str | bool = ( - os.getenv("SEND_INTRODUCTION_REMINDERS", default="Once").lower().strip() - ) - - if raw_send_introduction_reminders not in VALID_SEND_INTRODUCTION_REMINDERS_VALUES: - INVALID_SEND_INTRODUCTION_REMINDERS_MESSAGE: Final[str] = ( - 'SEND_INTRODUCTION_REMINDERS must be one of: "Once", "Interval" or "False".' - ) - raise ImproperlyConfiguredError(INVALID_SEND_INTRODUCTION_REMINDERS_MESSAGE) - - if raw_send_introduction_reminders in TRUE_VALUES: - raw_send_introduction_reminders = "once" - - elif raw_send_introduction_reminders not in ("once", "interval"): - raw_send_introduction_reminders = False - - cls._settings["SEND_INTRODUCTION_REMINDERS"] = raw_send_introduction_reminders - - @classmethod - def _setup_send_introduction_reminders_delay(cls) -> None: - if "SEND_INTRODUCTION_REMINDERS" not in cls._settings: - INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( - "Invalid setup order: SEND_INTRODUCTION_REMINDERS must be set up " - "before SEND_INTRODUCTION_REMINDERS_DELAY can be set up." - ) - raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) - - raw_send_introduction_reminders_delay: re.Match[str] | None = re.fullmatch( - pattern=r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?(?:(?P(?:\d*\.)?\d+)d)?(?:(?P(?:\d*\.)?\d+)w)?\Z", - string=( - os.getenv("SEND_INTRODUCTION_REMINDERS_DELAY", default="40h") - .strip() - .lower() - .replace(" ", "") - ), - ) - - raw_timedelta_send_introduction_reminders_delay: datetime.timedelta = ( - datetime.timedelta() - ) - - if cls._settings["SEND_INTRODUCTION_REMINDERS"]: - if not raw_send_introduction_reminders_delay: - INVALID_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_INTRODUCTION_REMINDERS_DELAY must contain the delay " - "in any combination of seconds, minutes, hours, days or weeks." - ) - raise ImproperlyConfiguredError( - INVALID_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE - ) - - raw_timedelta_send_introduction_reminders_delay = datetime.timedelta( - **{ - key: float(value) - for key, value in raw_send_introduction_reminders_delay.groupdict().items() - if value - } - ) - - if raw_timedelta_send_introduction_reminders_delay < datetime.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." - ) - raise ImproperlyConfiguredError( - TOO_SMALL_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE - ) - - cls._settings["SEND_INTRODUCTION_REMINDERS_DELAY"] = ( - raw_timedelta_send_introduction_reminders_delay - ) - - @classmethod - def _setup_send_introduction_reminders_interval(cls) -> None: - if "SEND_INTRODUCTION_REMINDERS" not in cls._settings: - INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( - "Invalid setup order: SEND_INTRODUCTION_REMINDERS must be set up " - "before SEND_INTRODUCTION_REMINDERS_INTERVAL can be set up." - ) - raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) - - raw_send_introduction_reminders_interval: re.Match[str] | None = re.fullmatch( - pattern=r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?\Z", - string=( - os.getenv("SEND_INTRODUCTION_REMINDERS_INTERVAL", default="6h") - .strip() - .lower() - .replace(" ", "") - ), - ) - - raw_timedelta_details_send_introduction_reminders_interval: Mapping[str, float] = { - "hours": 6 - } - - if cls._settings["SEND_INTRODUCTION_REMINDERS"]: - if not raw_send_introduction_reminders_interval: - INVALID_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE: Final[str] = ( - "SEND_INTRODUCTION_REMINDERS_INTERVAL must contain the interval " - "in any combination of seconds, minutes or hours." - ) - raise ImproperlyConfiguredError( - INVALID_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE - ) - - raw_timedelta_details_send_introduction_reminders_interval = { - key: float(value) - for key, value in raw_send_introduction_reminders_interval.groupdict().items() - if value - } - - if ( - datetime.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 = ( - os.getenv("SEND_GET_ROLES_REMINDERS", default="True").lower().strip() - ) - - if raw_send_get_roles_reminders not in TRUE_VALUES | FALSE_VALUES: - INVALID_SEND_GET_ROLES_REMINDERS_MESSAGE: Final[str] = ( - "SEND_GET_ROLES_REMINDERS must be a boolean value." - ) - raise ImproperlyConfiguredError(INVALID_SEND_GET_ROLES_REMINDERS_MESSAGE) - - cls._settings["SEND_GET_ROLES_REMINDERS"] = raw_send_get_roles_reminders in TRUE_VALUES - - @classmethod - def _setup_send_get_roles_reminders_delay(cls) -> None: - if "SEND_GET_ROLES_REMINDERS" not in cls._settings: - INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( - "Invalid setup order: SEND_GET_ROLES_REMINDERS must be set up " - "before SEND_GET_ROLES_REMINDERS_DELAY can be set up." - ) - raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) - - raw_send_get_roles_reminders_delay: re.Match[str] | None = re.fullmatch( - pattern=r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?(?:(?P(?:\d*\.)?\d+)d)?(?:(?P(?:\d*\.)?\d+)w)?\Z", - string=( - os.getenv("SEND_GET_ROLES_REMINDERS_DELAY", default="40h") - .strip() - .lower() - .replace(" ", "") - ), - ) - - raw_timedelta_send_get_roles_reminders_delay: datetime.timedelta = datetime.timedelta() - - if cls._settings["SEND_GET_ROLES_REMINDERS"]: - if not raw_send_get_roles_reminders_delay: - INVALID_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_GET_ROLES_REMINDERS_DELAY must contain the delay " - "in any combination of seconds, minutes, hours, days or weeks." - ) - raise ImproperlyConfiguredError(INVALID_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE) - - raw_timedelta_send_get_roles_reminders_delay = datetime.timedelta( - **{ - key: float(value) - for key, value in raw_send_get_roles_reminders_delay.groupdict().items() - if value - } - ) - - if raw_timedelta_send_get_roles_reminders_delay < datetime.timedelta(days=1): - TOO_SMALL_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_GET_ROLES_REMINDERS_DELAY must be longer than or equal to 1 day." - ) - raise ImproperlyConfiguredError( - TOO_SMALL_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE - ) - - cls._settings["SEND_GET_ROLES_REMINDERS_DELAY"] = ( - raw_timedelta_send_get_roles_reminders_delay - ) - - @classmethod - def _setup_advanced_send_get_roles_reminders_interval(cls) -> None: - if "SEND_GET_ROLES_REMINDERS" not in cls._settings: - INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( - "Invalid setup order: SEND_GET_ROLES_REMINDERS must be set up " - "before ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL can be set up." - ) - raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) - - raw_advanced_send_get_roles_reminders_interval: re.Match[str] | None = re.fullmatch( - pattern=r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?\Z", - string=( - os.getenv("ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL", default="24h") - .strip() - .lower() - .replace(" ", "") - ), - ) - - raw_timedelta_details_advanced_send_get_roles_reminders_interval: Mapping[ - str, float - ] = {"hours": 24} - - if cls._settings["SEND_GET_ROLES_REMINDERS"]: - if not raw_advanced_send_get_roles_reminders_interval: - INVALID_ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL_MESSAGE: Final[str] = ( - "ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL must contain the interval " - "in any combination of seconds, minutes or hours." - ) - raise ImproperlyConfiguredError( - INVALID_ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL_MESSAGE - ) - - raw_timedelta_details_advanced_send_get_roles_reminders_interval = { - key: float(value) - for key, value in ( - raw_advanced_send_get_roles_reminders_interval.groupdict().items() - ) - if value - } - - cls._settings["ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL"] = ( - raw_timedelta_details_advanced_send_get_roles_reminders_interval - ) - - @classmethod - def _setup_statistics_days(cls) -> None: - e: ValueError - try: - raw_statistics_days: float = float( - os.getenv("STATISTICS_DAYS", default="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 1 day." - ) - raise ImproperlyConfiguredError(TOO_SMALL_STATISTICS_DAYS_MESSAGE) - - cls._settings["STATISTICS_DAYS"] = datetime.timedelta(days=raw_statistics_days) - - @classmethod - def _setup_statistics_roles(cls) -> None: - raw_statistics_roles: str = os.getenv("STATISTICS_ROLES", default="").strip() - - if not raw_statistics_roles: - cls._settings["STATISTICS_ROLES"] = DEFAULT_STATISTICS_ROLES - return - - statistics_roles: AbstractSet[str] = { - raw_statistics_role.strip() - for raw_statistics_role in raw_statistics_roles.split(",") - if raw_statistics_role.strip() - } - - cls._settings["STATISTICS_ROLES"] = statistics_roles or DEFAULT_STATISTICS_ROLES - - @classmethod - def _setup_membership_dependent_roles(cls) -> None: - raw_membership_dependent_roles: str = os.getenv( - "MEMBERSHIP_DEPENDENT_ROLES", default="" - ).strip() - - if not raw_membership_dependent_roles: - cls._settings["MEMBERSHIP_DEPENDENT_ROLES"] = frozenset() - return - - cls._settings["MEMBERSHIP_DEPENDENT_ROLES"] = frozenset( - raw_membership_dependent_role.strip() - for raw_membership_dependent_role in raw_membership_dependent_roles.split(",") - if raw_membership_dependent_role.strip() - ) - - @classmethod - def _setup_moderation_document_url(cls) -> None: - INVALID_MODERATION_DOCUMENT_URL_MESSAGE: Final[str] = ( - "MODERATION_DOCUMENT_URL must be a valid URL." - ) - - raw_moderation_document_url: str = ( - os.getenv("MODERATION_DOCUMENT_URL", default="").strip().lower() - ) - - if not raw_moderation_document_url: - raise ImproperlyConfiguredError(INVALID_MODERATION_DOCUMENT_URL_MESSAGE) - - if not raw_moderation_document_url.startswith("https://"): - if "://" in raw_moderation_document_url: - INVALID_MODERATION_DOCUMENT_URL_PROTOCOL_MESSAGE: Final[str] = ( - "Only HTTPS is supported as a protocol for MODERATION_DOCUMENT_URL." - ) - raise ImproperlyConfiguredError( - INVALID_MODERATION_DOCUMENT_URL_PROTOCOL_MESSAGE - ) - - raw_moderation_document_url = "https://" + raw_moderation_document_url - logger.warning( - "MODERATION_DOCUMENT_URL was missing a URL protocol. " - "Please ensure all URLs are valid HTTPS URLs." - ) - - if not validators.url(raw_moderation_document_url): - raise ImproperlyConfiguredError(INVALID_MODERATION_DOCUMENT_URL_MESSAGE) - - cls._settings["MODERATION_DOCUMENT_URL"] = raw_moderation_document_url - - @classmethod - 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() - - 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 " - "of a channel in your group's Discord guild." - ) - raise ImproperlyConfiguredError(STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION_MESSAGE) - - cls._settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"] = ( - raw_strike_performed_manually_warning_location - ) - - @classmethod - def _setup_auto_add_committee_to_threads(cls) -> None: - raw_auto_add_committee_to_threads: str = ( - os.getenv("AUTO_ADD_COMMITTEE_TO_THREADS", default="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] = ( - "AUTO_ADD_COMMITTEE_TO_THREADS must be a boolean value." - ) - raise ImproperlyConfiguredError(INVALID_AUTO_ADD_COMMITTEE_TO_THREADS_MESSAGE) - - cls._settings["AUTO_ADD_COMMITTEE_TO_THREADS"] = ( - raw_auto_add_committee_to_threads in TRUE_VALUES - ) - - @classmethod - def _setup_env_variables(cls) -> None: - """ - Load environment values into the settings dictionary. - - Environment values are loaded from the .env file/the current environment variables and - are only stored after the input values have been validated. - """ - if cls._is_env_variables_setup: - logger.warning("Environment variables have already been set up.") - return - - dotenv.load_dotenv() - - webhook_config_logger: Logger = cls._setup_discord_log_channel_webhook() - - try: - cls._setup_console_logging() - cls._setup_discord_log_level() - cls._setup_discord_bot_token() - cls._setup_discord_guild_id() - cls._setup_group_full_name() - cls._setup_group_short_name() - cls._setup_ping_command_easter_egg_probability() - cls._setup_welcome_messages() - cls._setup_roles_messages() - cls._setup_organisation_id() - cls._setup_su_platform_access_cookie() - cls._setup_auto_su_platform_access_cookie_checking() - cls._setup_auto_su_platform_access_cookie_checking_interval() - cls._setup_membership_perks_url() - cls._setup_purchase_membership_url() - cls._setup_custom_discord_invite_url() - cls._setup_send_introduction_reminders() - cls._setup_send_introduction_reminders_delay() - cls._setup_send_introduction_reminders_interval() - cls._setup_send_get_roles_reminders() - cls._setup_send_get_roles_reminders_delay() - cls._setup_advanced_send_get_roles_reminders_interval() - cls._setup_statistics_days() - cls._setup_statistics_roles() - cls._setup_membership_dependent_roles() - cls._setup_moderation_document_url() - cls._setup_strike_performed_manually_warning_location() - cls._setup_auto_add_committee_to_threads() - except ImproperlyConfiguredError as improper_config_error: - webhook_config_logger.error(improper_config_error.message) # noqa: TRY400 - raise improper_config_error from improper_config_error - - cls._is_env_variables_setup = True - - -def _settings_class_factory() -> type[Settings]: - @final - class RuntimeSettings(Settings): # noqa: CAR160 - """ - Settings class that provides access to all settings values. - - Settings values can be accessed via key (like a dictionary) or via class attribute. - """ - - _is_env_variables_setup: "ClassVar[bool]" = False - _settings: "ClassVar[dict[str, object]]" = {} # noqa: RUF012 - - return RuntimeSettings - - -settings: "Final[Settings]" = _settings_class_factory()() - - -def run_setup() -> None: - """Execute the required setup functions.""" - settings._setup_env_variables() # noqa: SLF001 - - logger.debug("Begin database setup") - - importlib.import_module("db") - importlib.import_module("django.core.management").call_command("migrate") - - logger.debug("Database setup completed") diff --git a/config/__init__.py b/config/__init__.py index e53d40847..690cac3e0 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,24 +1,80 @@ """ Contains settings values and import & setup functions. -Settings values are imported from the .env file or the current environment variables. -These values are used to configure the functionality of the bot at run-time. +Settings values are loaded from the `tex-bot-deployment.yaml` deployment configuration +file, validated against the schema declared within `config._schema`. These values +configure the functionality of TeX-Bot at run-time, and can be reloaded without +restarting TeX-Bot. """ +import importlib import logging from typing import TYPE_CHECKING -from ._settings import SettingsAccessor +from ._accessor import SettingsAccessor, SettingsNotLoadedError, SettingsValidationError +from ._document import ( + InvalidSettingsFileError, + SettingsDocument, + SettingsFileNotFoundError, + get_settings_file_path, +) +from ._logging import apply_logging_settings +from ._messages import MessagesAccessor if TYPE_CHECKING: from collections.abc import Sequence + from collections.abc import Set as AbstractSet from logging import Logger from typing import Final -__all__: "Sequence[str]" = ("settings",) +__all__: "Sequence[str]" = ( + "InvalidSettingsFileError", + "SettingsDocument", + "SettingsFileNotFoundError", + "SettingsNotLoadedError", + "SettingsValidationError", + "get_settings_file_path", + "messages", + "reload_settings", + "run_setup", + "settings", +) logger: "Final[Logger]" = logging.getLogger("TeX-Bot") settings: "Final[SettingsAccessor]" = SettingsAccessor() + +messages: "Final[MessagesAccessor]" = MessagesAccessor() + + +def reload_settings() -> "AbstractSet[str]": + """ + Reload the deployment configuration file, applying any settings that have changed. + + Returns the set of settings key paths whose values have changed. + + The currently loaded configuration is left untouched if the file cannot be read or + contains invalid settings. + """ + CHANGED_SETTINGS: Final[AbstractSet[str]] = settings.reload() + + if any(changed_setting.startswith("logging:") for changed_setting in CHANGED_SETTINGS): + apply_logging_settings(settings.logging) + + return CHANGED_SETTINGS + + +def run_setup() -> None: + """Execute the setup functions required before TeX-Bot can be run.""" + reload_settings() + + messages.reload() + + logger.debug("Begin database setup") + + importlib.import_module("db") + importlib.import_module("django.core.management").call_command("migrate") + + logger.debug("Database setup completed") diff --git a/config/_logging.py b/config/_logging.py new file mode 100644 index 000000000..03a0a7f1f --- /dev/null +++ b/config/_logging.py @@ -0,0 +1,115 @@ +""" +Application of the logging configuration held within the settings. + +Logging is set up from the loaded settings rather than read directly from the +environment, so that changing a log level within the configuration file takes effect +upon the next reload without restarting TeX-Bot. +""" + +import logging +from typing import TYPE_CHECKING + +from discord_logging.handler import DiscordHandler + +if TYPE_CHECKING: + from collections.abc import Sequence + from logging import Handler, Logger + from typing import Final + + from ._schema import LoggingSettings + + +__all__: "Sequence[str]" = ("DISCORD_LOGGER_NAME", "LOGGER_NAME", "apply_logging_settings") + + +LOGGER_NAME: "Final[str]" = "TeX-Bot" + +DISCORD_LOGGER_NAME: "Final[str]" = "discord" + +DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME: "Final[str]" = "TeX-Bot" + +logger: "Final[Logger]" = logging.getLogger(LOGGER_NAME) + +discord_logger: "Final[Logger]" = logging.getLogger(DISCORD_LOGGER_NAME) + + +def _remove_handlers_of_type(target_logger: "Logger", handler_type: type) -> None: + """Remove every handler of the given type from the given logger.""" + existing_handler: Handler + for existing_handler in tuple(target_logger.handlers): + if isinstance(existing_handler, handler_type): + target_logger.removeHandler(existing_handler) + existing_handler.close() + + +def _apply_console_logging_settings(logging_settings: "LoggingSettings") -> None: + """Set up logging to the console output stream.""" + # NOTE: Handlers are replaced rather than reconfigured in place, so that applying + # settings repeatedly (upon every reload) cannot accumulate duplicate handlers. + _remove_handlers_of_type(logger, logging.StreamHandler) + + console_logging_handler: Handler = logging.StreamHandler() + console_logging_handler.setFormatter( + logging.Formatter("{asctime} | {name} | {levelname:^8} - {message}", style="{") + ) + + logger.setLevel(logging_settings.console.log_level) + logger.addHandler(console_logging_handler) + logger.propagate = False + + +def _apply_discord_channel_logging_settings(logging_settings: "LoggingSettings") -> None: + """Set up relaying of error logs to a Discord log channel.""" + _remove_handlers_of_type(logger, DiscordHandler) + + DISCORD_CHANNEL_LOGGING_SETTINGS = logging_settings.discord_channel + if DISCORD_CHANNEL_LOGGING_SETTINGS is None: + logger.debug( + "No Discord log-channel webhook-URL was set, " + "so error logs will not be sent to a Discord log-channel." + ) + return + + discord_channel_logging_handler: Handler = DiscordHandler( + DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME, + str(DISCORD_CHANNEL_LOGGING_SETTINGS.webhook_url), + ) + discord_channel_logging_handler.setLevel(DISCORD_CHANNEL_LOGGING_SETTINGS.log_level) + discord_channel_logging_handler.setFormatter( + logging.Formatter("{levelname} | {message}", style="{") + ) + + logger.addHandler(discord_channel_logging_handler) + + +def _apply_discord_api_logging_settings(logging_settings: "LoggingSettings") -> None: + """Set up recording of the logs emitted by the Discord API wrapper.""" + _remove_handlers_of_type(discord_logger, logging.FileHandler) + + DISCORD_API_LOGGING_SETTINGS = logging_settings.discord_api + if not DISCORD_API_LOGGING_SETTINGS.enabled: + discord_logger.propagate = True + return + + discord_api_logging_handler: Handler = logging.FileHandler( + filename=DISCORD_API_LOGGING_SETTINGS.file_name, encoding="utf-8", mode="a" + ) + discord_api_logging_handler.setFormatter( + logging.Formatter("{asctime}:{levelname}:{name}: {message}", style="{") + ) + + discord_logger.setLevel(DISCORD_API_LOGGING_SETTINGS.log_level) + discord_logger.addHandler(discord_api_logging_handler) + discord_logger.propagate = False + + +def apply_logging_settings(logging_settings: "LoggingSettings") -> None: + """ + Apply the given logging settings to every logger TeX-Bot writes to. + + Safe to call repeatedly: existing handlers are replaced rather than added to, + so reloading the configuration cannot accumulate duplicate handlers. + """ + _apply_console_logging_settings(logging_settings) + _apply_discord_channel_logging_settings(logging_settings) + _apply_discord_api_logging_settings(logging_settings) diff --git a/config/_messages.py b/config/_messages.py new file mode 100644 index 000000000..25d8e0147 --- /dev/null +++ b/config/_messages.py @@ -0,0 +1,148 @@ +""" +Loading of the response messages that TeX-Bot sends into Discord. + +Messages are held separately from the deployment configuration: they are a body of +content rather than a set of settings, so they live in their own JSON file and are +neither validated by the settings schema nor editable through the `/config` command. +""" + +import json +import os +from collections.abc import Iterable +from pathlib import Path +from typing import TYPE_CHECKING + +from exceptions import ( + ImproperlyConfiguredError, + MessagesJSONFileMissingKeyError, + MessagesJSONFileValueError, +) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from typing import Final + + +__all__: "Sequence[str]" = ("MESSAGES_FILE_PATH_ENVIRONMENT_VARIABLE_NAME", "MessagesAccessor") + + +PROJECT_ROOT: "Final[Path]" = Path(__file__).parent.parent.resolve() + +DEFAULT_MESSAGES_FILE_NAME: "Final[str]" = "messages.json" + +MESSAGES_FILE_PATH_ENVIRONMENT_VARIABLE_NAME: "Final[str]" = "MESSAGES_FILE_PATH" + + +def _get_messages_file_path() -> Path: + """Locate the messages JSON file.""" + RAW_MESSAGES_FILE_PATH: Final[str | None] = os.getenv( + MESSAGES_FILE_PATH_ENVIRONMENT_VARIABLE_NAME + ) + + messages_file_path: Path = ( + Path(RAW_MESSAGES_FILE_PATH.strip()) + if RAW_MESSAGES_FILE_PATH + else PROJECT_ROOT / DEFAULT_MESSAGES_FILE_NAME + ) + + if not messages_file_path.is_file(): + MESSAGES_FILE_DOES_NOT_EXIST_MESSAGE: str = ( + f"{MESSAGES_FILE_PATH_ENVIRONMENT_VARIABLE_NAME} must be a path " + f"to a file that exists." + ) + raise ImproperlyConfiguredError(MESSAGES_FILE_DOES_NOT_EXIST_MESSAGE) + + return messages_file_path + + +def _load_messages_file() -> "Mapping[str, object]": + """Read & decode the messages JSON file.""" + JSON_DECODING_ERROR_MESSAGE: Final[str] = ( + "Messages JSON file must contain a JSON string that can be decoded " + "into a Python dict object." + ) + + json_decode_error: json.JSONDecodeError + try: + raw_messages: object = json.loads( + _get_messages_file_path().read_text(encoding="utf-8") + ) + except json.JSONDecodeError as json_decode_error: + raise ImproperlyConfiguredError(JSON_DECODING_ERROR_MESSAGE) from json_decode_error + + if not isinstance(raw_messages, dict): + raise ImproperlyConfiguredError(JSON_DECODING_ERROR_MESSAGE) + + return raw_messages + + +def _get_message_set(raw_messages: "Mapping[str, object]", key: str) -> frozenset[str]: + """Retrieve a single set of messages from the decoded messages file.""" + if key not in raw_messages: + raise MessagesJSONFileMissingKeyError(missing_key=key) + + value: object = raw_messages[key] + + KEY_IS_VALID: Final[bool] = bool( + isinstance(value, Iterable) and not isinstance(value, (str, bytes)) and value + ) + if not KEY_IS_VALID: + raise MessagesJSONFileValueError(dict_key=key, invalid_value=value) + + if TYPE_CHECKING: + assert isinstance(value, Iterable) + + return frozenset(str(single_message) for single_message in value) + + +class MessagesAccessor: + """Provides access to the response messages that TeX-Bot sends into Discord.""" + + def __init__(self) -> None: + """Initialise an accessor holding no messages until they are first loaded.""" + self._welcome_messages: frozenset[str] | None = None + self._roles_messages: frozenset[str] | None = None + + @property + def is_loaded(self) -> bool: + """Whether any messages have been loaded yet.""" + return self._welcome_messages is not None and self._roles_messages is not None + + def reload(self) -> None: + """ + Load the messages JSON file, replacing any previously loaded messages. + + Both sets of messages are read before either is stored, so a malformed file + leaves any previously loaded messages untouched. + """ + RAW_MESSAGES: Final[Mapping[str, object]] = _load_messages_file() + + WELCOME_MESSAGES: Final[frozenset[str]] = _get_message_set( + RAW_MESSAGES, "welcome_messages" + ) + ROLES_MESSAGES: Final[frozenset[str]] = _get_message_set( + RAW_MESSAGES, "roles_messages" + ) + + self._welcome_messages = WELCOME_MESSAGES + self._roles_messages = ROLES_MESSAGES + + def _get_loaded(self, messages: frozenset[str] | None) -> frozenset[str]: + """Return the given set of messages, raising if they have not been loaded.""" + if messages is None: + MESSAGES_NOT_LOADED_MESSAGE: str = ( + "Messages cannot be accessed before they have been loaded." + ) + raise RuntimeError(MESSAGES_NOT_LOADED_MESSAGE) + + return messages + + @property + def welcome_messages(self) -> frozenset[str]: + """The set of messages welcoming a new member into the community group.""" + return self._get_loaded(self._welcome_messages) + + @property + def roles_messages(self) -> frozenset[str]: + """The set of messages describing the opt-in roles that a member can request.""" + return self._get_loaded(self._roles_messages) diff --git a/config/_schema.py b/config/_schema.py index dc4a2da89..dc896f2eb 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -437,8 +437,10 @@ class CommunityGroupSettings(_BaseSettingsSchema): # type: ignore[explicit-any] ), json_schema_extra={"requires_restart": False, "secret": False}, ) - membership_dependent_roles: UniqueStrSequence | None = Field( - default=None, + membership_dependent_roles: UniqueStrSequence = Field( + # NOTE: Defaults to no roles, rather than being absent, so that consumers can + # always iterate it or test membership of it without a null check first. + default=(), description=( "The names of the roles that should only be held " "by members of your community group." @@ -502,6 +504,16 @@ class StatsCommandSettings(_BaseSettingsSchema): # type: ignore[explicit-any] json_schema_extra={"requires_restart": False, "secret": False}, ) + @property + def lookback_period(self) -> datetime.timedelta: + """ + The period of time to look back over messages sent, to generate statistics data. + + Held within the configuration file as a plain number of days, because that reads + far more naturally than a duration string for a value of this size. + """ + return datetime.timedelta(days=self.lookback_days) + class StrikeCommandSettings(_BaseSettingsSchema): # type: ignore[explicit-any] """Settings controlling the behaviour of the `/strike` command.""" diff --git a/config/_settings/__init__.py b/config/_settings/__init__.py deleted file mode 100644 index e40b8e1cd..000000000 --- a/config/_settings/__init__.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -Contains settings values and setup functions. - -Settings values are imported from the tex-bot-deployment.yaml file. -These values are used to configure the functionality of the bot at run-time. -""" - -import datetime -import logging -import re -from typing import TYPE_CHECKING - -import utils - -if TYPE_CHECKING: - from collections.abc import Sequence - from logging import Logger - from typing import ClassVar, Final - - from strictyaml import YAML - - -logger: "Final[Logger]" = logging.getLogger("TeX-Bot") - - -class SettingsAccessor: - """ - Settings class that provides access to the settings values. - - Settings values can be accessed via key (like a dictionary) or via class attributes. - """ - - _settings: "ClassVar[dict[str, object]]" = {} - _most_recent_yaml: "ClassVar[YAML | None]" = None - - @classmethod - def _get_invalid_settings_key_message(cls, item: str) -> str: - """Return the message to state that the given settings key is invalid.""" - return f"{item!r} is not a valid settings key." - - @classmethod - async def restore_default(cls, config_setting_name: str) -> None: - """ - Set the specified setting to its default value. - - If the setting does not have a default, it will be removed. - """ - return - - def __getattr__(self, item: str) -> object: - """Retrieve settings value by attribute lookup.""" - MISSING_ATTRIBUTE_MESSAGE: Final[str] = ( - f"{type(self).__name__!r} object has no attribute {item!r}" - ) - - if "_pytest" in item or item in ("__bases__", "__test__"): # NOTE: Overriding __getattr__() leads to many edge-case issues where external libraries will attempt to call getattr() with peculiar values - raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) - - IN_SETTING_KEY_FORMAT: Final[bool] = bool( - re.fullmatch(r"\A(?!.*__.*)(?:[A-Z]|[A-Z_][A-Z]|[A-Z_][A-Z][A-Z_]*[A-Z])\Z", item) - ) - if not IN_SETTING_KEY_FORMAT: - raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) - - if self._most_recent_yaml is None: - YAML_NOT_LOADED_MESSAGE: Final[str] = ( - "Configuration cannot be accessed before it is loaded." - ) - raise RuntimeError(YAML_NOT_LOADED_MESSAGE) - - if item not in self._settings: - INVALID_SETTINGS_KEY_MESSAGE: Final[str] = self._get_invalid_settings_key_message( - item, - ) - raise AttributeError(INVALID_SETTINGS_KEY_MESSAGE) - - ATTEMPTING_TO_ACCESS_BOT_TOKEN_WHEN_ALREADY_RUNNING: Final[bool] = bool( - "bot" in item.lower() and "token" in item.lower() and utils.is_running_in_async() - ) - if ATTEMPTING_TO_ACCESS_BOT_TOKEN_WHEN_ALREADY_RUNNING: - TEX_BOT_ALREADY_RUNNING_MESSAGE: Final[str] = ( - f"Cannot access {item!r} when TeX-Bot is already running." - ) - raise RuntimeError(TEX_BOT_ALREADY_RUNNING_MESSAGE) - - return self._settings[item] - - def __getitem__(self, item: str) -> object: - """Retrieve settings value by key lookup.""" - attribute_not_exist_error: AttributeError - try: - return getattr(self, item) - except AttributeError as attribute_not_exist_error: - key_error_message: str = item - - ERROR_WAS_FROM_INVALID_KEY_NAME: Final[bool] = ( - self._get_invalid_settings_key_message(item) in str( - attribute_not_exist_error, - ) - ) - if ERROR_WAS_FROM_INVALID_KEY_NAME: - key_error_message = str(attribute_not_exist_error) - - raise KeyError(key_error_message) from None - diff --git a/config/_settings/_yaml/__init__.py b/config/_settings/_yaml/__init__.py deleted file mode 100644 index 61fd476cb..000000000 --- a/config/_settings/_yaml/__init__.py +++ /dev/null @@ -1,162 +0,0 @@ -from typing import TYPE_CHECKING - -import strictyaml - -from .custom_scalar_validators import ( - BoundedFloatValidator, - CustomBoolValidator, - DiscordSnowflakeValidator, - DiscordWebhookURLValidator, - LogLevelValidator, - SendIntroductionRemindersFlagValidator, - TimeDeltaValidator, -) - -if TYPE_CHECKING: - from collections.abc import Mapping, Sequence - from typing import Final - - from config.constants import ( - LogLevels, - SendIntroductionRemindersFlagType, - ) - - -__all__: "Sequence[str]" = () - -from config.constants import ( - DEFAULT_AUTO_ADD_COMMITTEE_TO_THREADS, - DEFAULT_CONSOLE_LOG_LEVEL, - DEFAULT_DISCORD_API_LOGGING_ENABLED, - DEFAULT_DISCORD_API_LOGGING_FILE_NAME, - DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL, - DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED, - DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL, - DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, - DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, - DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED, - DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, - DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, - DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED, - DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, - DEFAULT_STATS_COMMAND_DISPLAYED_ROLES, - DEFAULT_STATS_COMMAND_LOOKBACK_DAYS, - DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, - DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, - DEFAULT_STRIKE_REPORTED_MESSAGE_DESTINATION_CHANNEL, -) - -_DEFAULT_CONSOLE_LOGGING_SETTINGS: "Final[Mapping[str, LogLevels]]" = { - "log-level": DEFAULT_CONSOLE_LOG_LEVEL, -} -_DEFAULT_DISCORD_API_LOGGING_SETTINGS: "Final[Mapping[str, bool | str]]" = { - "enabled": DEFAULT_DISCORD_API_LOGGING_ENABLED, - "log-level": DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL, - "file-name": DEFAULT_DISCORD_API_LOGGING_FILE_NAME, -} -_DEFAULT_LOGGING_SETTINGS: "Final[Mapping[str, Mapping[str, LogLevels | bool | str]]]" = { - "console": _DEFAULT_CONSOLE_LOGGING_SETTINGS, - "discord-api": _DEFAULT_DISCORD_API_LOGGING_SETTINGS, -} -_DEFAULT_PING_COMMAND_SETTINGS: "Final[Mapping[str, float]]" = { - "easter-egg-probability": DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, -} -_DEFAULT_STATS_COMMAND_SETTINGS: "Final[Mapping[str, float | Sequence[str]]]" = { - "lookback-days": DEFAULT_STATS_COMMAND_LOOKBACK_DAYS, - "displayed-roles": DEFAULT_STATS_COMMAND_DISPLAYED_ROLES, -} -_DEFAULT_STRIKE_COMMAND_SETTINGS: "Final[Mapping[str, str]]" = { - "timeout-duration": DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, - "performed-manually-warning-location": DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, -} -_DEFAULT_COMMANDS_SETTINGS: "Final[Mapping[str, Mapping[str, float] | Mapping[str, float | Sequence[str]] | Mapping[str, str]]]" = { - "ping": _DEFAULT_PING_COMMAND_SETTINGS, - "stats": _DEFAULT_STATS_COMMAND_SETTINGS, - "strike": _DEFAULT_STRIKE_COMMAND_SETTINGS, -} -_DEFAULT_MSL_AUTO_COOKIE_CHECKING_SETTINGS: "Final[Mapping[str, bool | str]]" = { - "enabled": DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED, - "interval": DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL, -} -_DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS: "Final[Mapping[str, SendIntroductionRemindersFlagType | str]]" = { - "enabled": DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED, - "delay": DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, - "interval": DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, -} -_DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS: "Final[Mapping[str, bool | str]]" = { - "enabled": DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED, - "delay": DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, - "interval": DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, -} -_DEFAULT_REMINDERS_SETTINGS: "Final[Mapping[str, Mapping[str, bool | str] | Mapping[str, SendIntroductionRemindersFlagType | str]]]" = { - "send-introduction-reminders": _DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS, - "send-get-roles-reminders": _DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS, -} - - -SETTINGS_YAML_SCHEMA: "Final[strictyaml.Validator]" = strictyaml.Map({ - strictyaml.Optional("logging", default=_DEFAULT_LOGGING_SETTINGS): strictyaml.Map({ - strictyaml.Optional("console", default=_DEFAULT_CONSOLE_LOGGING_SETTINGS): strictyaml.Map({ - strictyaml.Optional("log-level", default=DEFAULT_CONSOLE_LOG_LEVEL): LogLevelValidator(), - }), - strictyaml.Optional("discord-channel", default=_DEFAULT_CONSOLE_LOGGING_SETTINGS): strictyaml.Map({ - "webhook-url": DiscordWebhookURLValidator(), - strictyaml.Optional("log-level", default=DEFAULT_CONSOLE_LOG_LEVEL): LogLevelValidator(), - }), - strictyaml.Optional("discord-api", default=_DEFAULT_DISCORD_API_LOGGING_SETTINGS): strictyaml.Map({ - strictyaml.Optional("enabled", default=DEFAULT_DISCORD_API_LOGGING_ENABLED): CustomBoolValidator(), - strictyaml.Optional("log-level", default=DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL): LogLevelValidator(), - strictyaml.Optional("file-name", default=DEFAULT_DISCORD_API_LOGGING_FILE_NAME): strictyaml.Str(), - }), - }), - "discord": strictyaml.Map({ - "bot-token": strictyaml.Regex(r"\A(?!.*__.*)(?!.*--.*)(?:([A-Za-z0-9]{24,26})\.([A-Za-z0-9]{6})\.([A-Za-z0-9_-]{27,38}))\Z"), - "main-guild-id": DiscordSnowflakeValidator(), - }), - "community-group": strictyaml.Map({ - strictyaml.Optional("full-name"): strictyaml.Regex(r"\A.{1,50}\Z"), - strictyaml.Optional("short-name"): strictyaml.Regex(r"\A(?!.*['&!?:,.#%\"-]['&!?:,.#%\"-].*)(?:[A-Za-z0-9'&!?:,.#%\"-]+)\Z",), - strictyaml.Optional("membership-dependent-roles"): strictyaml.UniqueSeq(strictyaml.Str()), - "links": strictyaml.Map({ - strictyaml.Optional("purchase-membership"): strictyaml.Url(), - strictyaml.Optional("membership-perks"): strictyaml.Url(), - strictyaml.Optional("moderation-policy"): strictyaml.Url(), - strictyaml.Optional("custom-discord-invite-link"): strictyaml.Url(), - }), - "msl": strictyaml.Map({ - strictyaml.Optional("organisation-id"): strictyaml.Regex(r"\A\d{4,5}\Z"), - strictyaml.Optional("auth-cookie"): strictyaml.Regex(r"\A[\w-]{512,1024}\Z"), - strictyaml.Optional("auto-cookie-checking", default=_DEFAULT_MSL_AUTO_COOKIE_CHECKING_SETTINGS): strictyaml.Map({ - strictyaml.Optional("enabled", default=DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED): CustomBoolValidator(), - strictyaml.Optional("interval", default=DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL): TimeDeltaValidator(minutes=True, hours=True, days=True), - }), - }), - }), - strictyaml.Optional("commands", default=_DEFAULT_COMMANDS_SETTINGS): strictyaml.Map({ - strictyaml.Optional("ping", default=_DEFAULT_PING_COMMAND_SETTINGS): strictyaml.Map({ - strictyaml.Optional("easter-egg-probability", default=DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY): BoundedFloatValidator(0, 1), - }), - strictyaml.Optional("stats", default=_DEFAULT_STATS_COMMAND_SETTINGS): strictyaml.Map({ - strictyaml.Optional("lookback-days", default=DEFAULT_STATS_COMMAND_LOOKBACK_DAYS): BoundedFloatValidator(5, 1826), - strictyaml.Optional("displayed-roles", default=DEFAULT_STATS_COMMAND_DISPLAYED_ROLES): strictyaml.UniqueSeq(strictyaml.Str()), - }), - strictyaml.Optional("strike", default=_DEFAULT_STRIKE_COMMAND_SETTINGS): strictyaml.Map({ - strictyaml.Optional("performed-manually-warning-location", default=DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION): strictyaml.Str(), - strictyaml.Optional("timeout-duration", default=DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION): TimeDeltaValidator(minutes=True, hours=True, days=True), - strictyaml.Optional("reported-message-destination-channel", default=DEFAULT_STRIKE_REPORTED_MESSAGE_DESTINATION_CHANNEL): strictyaml.Str(), - }), - }), - strictyaml.Optional("reminders", default=_DEFAULT_REMINDERS_SETTINGS): strictyaml.Map({ - strictyaml.Optional("send-introduction-reminders", default=_DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS): strictyaml.Map({ - strictyaml.Optional("enabled", default=DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED): SendIntroductionRemindersFlagValidator(), - strictyaml.Optional("delay", default=DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY): TimeDeltaValidator(minutes=True, hours=True, days=True), - strictyaml.Optional("interval", default=DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL): TimeDeltaValidator(minutes=True, hours=True, days=True), - }), - strictyaml.Optional("send-get-roles-reminders", default=_DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS): strictyaml.Map({ - strictyaml.Optional("enabled", default=DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED): CustomBoolValidator(), - strictyaml.Optional("delay", default=DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY): TimeDeltaValidator(minutes=True, hours=True, days=True), - strictyaml.Optional("interval", default=DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL): TimeDeltaValidator(minutes=True, hours=True, days=True), - }), - }), - strictyaml.Optional("auto-add-committee-to-threads", default=DEFAULT_AUTO_ADD_COMMITTEE_TO_THREADS): CustomBoolValidator(), -}) diff --git a/config/_settings/_yaml/custom_scalar_validators.py b/config/_settings/_yaml/custom_scalar_validators.py deleted file mode 100644 index a18961674..000000000 --- a/config/_settings/_yaml/custom_scalar_validators.py +++ /dev/null @@ -1,358 +0,0 @@ -from collections.abc import Sequence - -__all__: Sequence[str] = ( - "BoundedFloatValidator", - "CustomBoolValidator", - "DiscordSnowflakeValidator", - "DiscordWebhookURLValidator", - "LogLevelValidator", - "RegexMatcher", - "SendIntroductionRemindersFlagValidator", - "TimeDeltaValidator", -) - - -import datetime -import math -import re -from typing import TYPE_CHECKING, override - -import strictyaml -from strictyaml import constants as strictyaml_constants -from strictyaml import utils as strictyaml_utils -from strictyaml.exceptions import YAMLSerializationError - -from config.constants import ( - VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES, - LogLevels, -) - -if TYPE_CHECKING: - from typing import Final, Literal, NoReturn - - from strictyaml.yamllocation import YAMLChunk - - from config.constants import ( - SendIntroductionRemindersFlagType, - ) - - -class LogLevelValidator(strictyaml.ScalarValidator): - @override - def validate_scalar(self, chunk: "YAMLChunk") -> LogLevels: - val: str = str(chunk.contents).upper().strip(" \n\t-_.") - - if val not in LogLevels: - chunk.expecting_but_found( - f"when expecting a valid log-level (one of: '{"', '".join(LogLevels)}')", - ) - raise RuntimeError - - return val # type: ignore[return-value] - - @override - def to_yaml(self, data: object) -> str: - self.should_be_string(data, "expected a valid log-level.") - str_data: str = data.upper().strip(" \n\t-_.") # type: ignore[attr-defined] - - if str_data not in LogLevels: - INVALID_DATA_MESSAGE: Final[str] = ( - f"Got '{data}' when expecting one of: '{"', '".join(LogLevels)}'." - ) - raise YAMLSerializationError(INVALID_DATA_MESSAGE) - - return str_data - - -class DiscordWebhookURLValidator(strictyaml.Url): - @override - def validate_scalar(self, chunk: "YAMLChunk") -> str: - CHUNK_IS_VALID: Final[bool] = bool( - super().__is_absolute_url(chunk.contents) - and chunk.contents.startswith("https://discord.com/api/webhooks/") - ) - if not CHUNK_IS_VALID: - chunk.expecting_but_found("when expecting a Discord webhook URL") - raise RuntimeError - - return chunk.contents - - @override - def to_yaml(self, data: object) -> str: - self.should_be_string(data, "expected a URL,") - - DATA_IS_VALID: Final[bool] = bool( - super().__is_absolute_url(str(data)) - and str(data).startswith("https://discord.com/api/webhooks/") - ) - if not DATA_IS_VALID: - INVALID_DATA_MESSAGE: Final[str] = f"'{data}' is not a Discord webhook URL." - raise YAMLSerializationError(INVALID_DATA_MESSAGE) - - return str(data) - - -class DiscordSnowflakeValidator(strictyaml.Int): - @override - def validate_scalar(self, chunk: "YAMLChunk") -> int: - val: int = super().validate_scalar(chunk) - - if not re.fullmatch(r"\A\d{17,20}\Z", str(val)): - chunk.expecting_but_found("when expecting a Discord snowflake ID") - raise RuntimeError - - return val - - @override - def to_yaml(self, data: object) -> str: - DATA_IS_VALID: Final[bool] = bool( - (strictyaml_utils.is_string(data) or isinstance(data, int)) - and strictyaml_utils.is_integer(str(data)) - and re.fullmatch(r"\A\d{17,20}\Z", str(data)) - ) - if not DATA_IS_VALID: - INVALID_DATA_MESSAGE: Final[str] = f"'{data}' is not a Discord snowflake ID." - raise YAMLSerializationError(INVALID_DATA_MESSAGE) - - return str(data) - - -class RegexMatcher(strictyaml.ScalarValidator): - MATCHING_MESSAGE: str = "when expecting a regular expression matcher" - - @override - def validate_scalar(self, chunk: "YAMLChunk") -> str: - try: - re.compile(chunk.contents) - except re.error: - chunk.expecting_but_found( - self.MATCHING_MESSAGE, - "found arbitrary string", - ) - - return chunk.contents # type: ignore[no-any-return] - - @override - def to_yaml(self, data: object) -> str: - self.should_be_string(data, self.MATCHING_MESSAGE) - - try: - re.compile(data) # type: ignore[call-overload] - except re.error as regex_error: - INVALID_DATA_MESSAGE: Final[str] = f"{self.MATCHING_MESSAGE} found '{data}'" - raise YAMLSerializationError(INVALID_DATA_MESSAGE) from regex_error - - return data # type: ignore[return-value] - - -class BoundedFloatValidator(strictyaml.Float): - @override - def __init__(self, inclusive_minimum: float, inclusive_maximum: float) -> None: - self.inclusive_minimum: float = inclusive_minimum - self.inclusive_maximum: float = inclusive_maximum - - super().__init__() - - @override - def validate_scalar(self, chunk: "YAMLChunk") -> float: - val: float = super().validate_scalar(chunk) - - if not self.inclusive_minimum <= val <= self.inclusive_maximum: - chunk.expecting_but_found( - ( - "when expecting a float " - f"between {self.inclusive_minimum} & {self.inclusive_maximum}" - ), - ) - raise RuntimeError - - return val - - @override - def to_yaml(self, data: object) -> str: - YAML_SERIALIZATION_ERROR: Final[YAMLSerializationError] = YAMLSerializationError( - ( - f"'{data}' is not a float " - f"between {self.inclusive_minimum} & {self.inclusive_maximum}." - ), - ) - - if strictyaml_utils.is_string(data) and strictyaml_utils.is_decimal(data): - data = float(str(data)) - - if not strictyaml_utils.has_number_type(data): - raise YAML_SERIALIZATION_ERROR - - if not self.inclusive_minimum <= data <= self.inclusive_maximum: # type: ignore[operator] - raise YAML_SERIALIZATION_ERROR - - if math.isnan(data): # type: ignore[arg-type] - return "nan" - if data == float("inf"): - return "inf" - if data == float("-inf"): - return "-inf" - - return str(data) - - -class TimeDeltaValidator(strictyaml.ScalarValidator): - @override - def __init__( - self, - *, - seconds: "Literal[True]" = True, - minutes: bool = True, - hours: bool = True, - days: bool = False, - weeks: bool = False, - ) -> None: - regex_matcher: str = r"\A" - - time_resolution_name: str - for time_resolution_name in ("seconds", "minutes", "hours", "days", "weeks"): - formatted_time_resolution_name: str = time_resolution_name.lower().strip() - time_resolution: object = locals()[formatted_time_resolution_name] - - if not isinstance(time_resolution, bool): - raise TypeError - - if not time_resolution: - continue - - regex_matcher += ( - r"(?:(?P<" - + formatted_time_resolution_name - + r">(?:\d*\.)?\d+)" - + formatted_time_resolution_name[0] - + ")?" - ) - - regex_matcher += r"\Z" - - self.regex_matcher: re.Pattern[str] = re.compile(regex_matcher) - - def _get_value_from_match(self, match: re.Match[str], key: str) -> float: - if key not in self.regex_matcher.groupindex: - return 0.0 - - value: str | None = match.group(key) - - if not value: - return 0.0 - - try: - return float(value) - except ValueError as float_conversion_error: - raise float_conversion_error from float_conversion_error - - @override - def validate_scalar(self, chunk: "YAMLChunk") -> datetime.timedelta: - def chunk_error_func() -> "NoReturn": - chunk.expecting_but_found( - expecting="when expecting a delay/interval string", - found="found non-matching string", - ) - raise RuntimeError - - match: re.Match[str] | None = self.regex_matcher.fullmatch(chunk.contents) - if match is None: - chunk_error_func() - - try: - return datetime.timedelta( - seconds=self._get_value_from_match(match, "seconds"), - minutes=self._get_value_from_match(match, "minutes"), - hours=self._get_value_from_match(match, "hours"), - days=self._get_value_from_match(match, "days"), - weeks=self._get_value_from_match(match, "weeks"), - ) - except ValueError: - chunk_error_func() - - @override - def to_yaml(self, data: object) -> str: - if strictyaml_utils.is_string(data): - match: re.Match[str] | None = self.regex_matcher.fullmatch(str(data)) - if match is None: - INVALID_STRING_DATA_MESSAGE: Final[str] = ( - f"when expecting a delay/interval string found {str(data)!r}." - ) - raise YAMLSerializationError(INVALID_STRING_DATA_MESSAGE) - return str(data) - - if not hasattr(data, "total_seconds") or not callable(data.total_seconds): - INVALID_TIMEDELTA_DATA_MESSAGE: Final[str] = ( - f"when expecting a time delta object found {str(data)!r}." - ) - raise YAMLSerializationError(INVALID_TIMEDELTA_DATA_MESSAGE) - - total_seconds: object = data.total_seconds - if not isinstance(total_seconds, float): - raise TypeError - - if (total_seconds / 3600) % 1 == 0: - return f"{int(total_seconds / 3600)}h" - - if total_seconds % 1 == 0: - return f"{int(total_seconds)}s" - - return f"{total_seconds}s" - - -class SendIntroductionRemindersFlagValidator(strictyaml.ScalarValidator): - @override - def validate_scalar(self, chunk: "YAMLChunk") -> "SendIntroductionRemindersFlagType": - val: str = str(chunk.contents).lower() - - if val not in VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES: - chunk.expecting_but_found( - ( - "when expecting a send-introduction-reminders-flag " - "(one of: 'once', 'interval' or 'false')" - ), - ) - raise RuntimeError - - if val in strictyaml_constants.TRUE_VALUES: - return "once" - - if val not in ("once", "interval"): - return False - - return val # type: ignore[return-value] - - @override - def to_yaml(self, data: object) -> str: - if isinstance(data, bool): - return "once" if data else "false" - - if str(data).lower() not in VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES: - INVALID_DATA_MESSAGE: Final[str] = ( - f"Got '{data}' when expecting one of: 'once', 'interval' or 'false'." - ) - raise YAMLSerializationError(INVALID_DATA_MESSAGE) - - if str(data).lower() in strictyaml_constants.TRUE_VALUES: - return "once" - - if str(data).lower() in strictyaml_constants.FALSE_VALUES: - return "false" - - return str(data).lower() - - -class CustomBoolValidator(strictyaml.Bool): - @override - def to_yaml(self, data: object) -> str: - if isinstance(data, bool): - return "true" if data else "false" - - if str(data).lower() in strictyaml_constants.TRUE_VALUES: - return "true" - - if str(data).lower() in strictyaml_constants.FALSE_VALUES: - return "false" - - INVALID_TYPE_MESSAGE: Final[str] = "Not a boolean" - raise YAMLSerializationError(INVALID_TYPE_MESSAGE) diff --git a/config/constants.py b/config/constants.py deleted file mode 100644 index b19e9e836..000000000 --- a/config/constants.py +++ /dev/null @@ -1,507 +0,0 @@ -"""Constant values that are defined for quick access.""" - -from enum import Enum, EnumMeta -from pathlib import Path -from typing import TYPE_CHECKING, Literal, NamedTuple, override - -from strictyaml import constants as strictyaml_constants - -if TYPE_CHECKING: - from collections.abc import Iterable, Mapping, Sequence - from typing import Final, TypeAlias - - -__all__: "Sequence[str]" = ( - "CONFIG_SETTINGS_HELPS", - "DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL", - "DEFAULT_CONSOLE_LOG_LEVEL", - "DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME", - "DEFAULT_DISCORD_API_LOGGING_ENABLED", - "DEFAULT_DISCORD_API_LOGGING_FILE_NAME", - "DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL", - "DEFAULT_DISCORD_LOGGING_LOG_LEVEL", - "DEFAULT_MEMBERS_LIST_ID_FORMAT", - "DEFAULT_MESSAGE_LOCALE_CODE", - "DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY", - "DEFAULT_AUTO_ADD_COMMITTEE_TO_THREADS", - "DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY", - "DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED", - "DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL", - "DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY", - "DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED", - "DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL", - "DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED", - "DEFAULT_STATS_COMMAND_DISPLAYED_ROLES", - "DEFAULT_STATS_COMMAND_LOOKBACK_DAYS", - "DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION", - "DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION", - "MESSAGES_LOCALE_CODES", - "PROJECT_ROOT", - "VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES", - "ConfigSettingHelp", - "LogLevels", - "SendIntroductionRemindersFlagType", -) - -SendIntroductionRemindersFlagType: "TypeAlias" = Literal["once", "interval", False] - - -class MetaEnum(EnumMeta): - @override - def __contains__(cls, item: object) -> bool: - try: - cls(item) - except ValueError: - return False - return True - - -class LogLevels(str, Enum, metaclass=MetaEnum): # noqa: UP042 - """Set of valid string values used for logging log-levels.""" - - DEBUG = "DEBUG" - INFO = "INFO" - WARNING = "WARNING" - ERROR = "ERROR" - CRITICAL = "CRITICAL" - - -class ConfigSettingHelp(NamedTuple): - """Container to hold help information about a single configuration setting.""" - - description: str - value_type_message: str | None - requires_restart_after_changed: bool - required: bool = True - default: str | None = None - - -def _selectable_required_format_message(options: "Iterable[str]") -> str: - return f"Must be one of: `{'`, `'.join(options)}`." - - -def _custom_required_format_message(type_value: str, info_link: str | None = None) -> str: - return f"Must be a valid { - type_value.lower() - .replace('discord', 'Discord') - .replace( - 'id', - 'ID', - ) - .replace('url', 'URL') - .replace('dm', 'DM') - .strip('.') - }{f' (see <{info_link}>)' if info_link else ''}." - - -PROJECT_ROOT: "Final[Path]" = Path(__file__).parent.parent.resolve() - -MESSAGES_LOCALE_CODES: "Final[frozenset[str]]" = frozenset({"en-GB"}) - - -VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES: "Final[frozenset[str]]" = frozenset( - ({"once", "interval"} | set(strictyaml_constants.BOOL_VALUES)), -) - -DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME: "Final[str]" = "TeX-Bot" - -DEFAULT_DISCORD_API_LOGGING_ENABLED: "Final[bool]" = False -DEFAULT_DISCORD_API_LOGGING_LOG_LEVEL: "Final[LogLevels]" = LogLevels.INFO -DEFAULT_DISCORD_API_LOGGING_FILE_NAME: "Final[str]" = "discord.log" - -DEFAULT_CONSOLE_LOG_LEVEL: "Final[LogLevels]" = LogLevels.INFO -DEFAULT_DISCORD_LOGGING_LOG_LEVEL: "Final[LogLevels]" = LogLevels.WARNING -DEFAULT_MEMBERS_LIST_ID_FORMAT: "Final[str]" = r"\A\d{6,7}\Z" -DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY: "Final[float]" = 0.01 -DEFAULT_STATS_COMMAND_LOOKBACK_DAYS: "Final[float]" = 30.0 -DEFAULT_STATS_COMMAND_DISPLAYED_ROLES: "Final[Sequence[str]]" = [ - "Committee", - "Committee-Elect", - "Student Rep", - "Member", - "Guest", - "Server Booster", - "Foundation Year", - "First Year", - "Second Year", - "Final Year", - "Year In Industry", - "Year Abroad", - "PGT", - "PGR", - "Alumnus/Alumna", - "Postdoc", - "Quiz Victor", -] -DEFAULT_STRIKE_REPORTED_MESSAGE_DESTINATION_CHANNEL: "Final[str]" = "discord" -DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION: "Final[str]" = "24h" -DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION: "Final[str]" = "DM" -DEFAULT_MESSAGE_LOCALE_CODE: "Final[str]" = "en-GB" -DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED: "Final[SendIntroductionRemindersFlagType]" = ( - "once" -) -DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY: "Final[str]" = "40h" -DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL: "Final[str]" = "6h" -DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED: "Final[bool]" = True -DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY: "Final[str]" = "40h" -DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL: "Final[str]" = "6h" -DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL: "Final[str]" = "30s" - -DEFAULT_MSL_AUTO_COOKIE_CHECKING_ENABLED: "Final[bool]" = False -DEFAULT_MSL_AUTO_COOKIE_CHECKING_INTERVAL: "Final[str]" = "10m" - -DEFAULT_AUTO_ADD_COMMITTEE_TO_THREADS: "Final[bool]" = True - -CONFIG_SETTINGS_HELPS: "Mapping[str, ConfigSettingHelp]" = { - "logging:console:log-level": ConfigSettingHelp( - description=( - "The minimum level that logs must meet in order to be logged " - "to the console output stream." - ), - value_type_message=_selectable_required_format_message(LogLevels), - requires_restart_after_changed=False, - required=False, - default=DEFAULT_CONSOLE_LOG_LEVEL, - ), - "logging:discord-channel:log-level": ConfigSettingHelp( - description=( - "The minimum level that logs must meet in order to be logged " - "to the Discord log channel." - ), - value_type_message=_selectable_required_format_message(LogLevels), - requires_restart_after_changed=False, - required=False, - default=DEFAULT_DISCORD_LOGGING_LOG_LEVEL, - ), - "logging:discord-channel:webhook-url": ConfigSettingHelp( - description=( - "The webhook URL of the Discord text channel where error logs should be sent.\n" - "Error logs will always be sent to the console, " - "this setting allows them to also be sent to a Discord log channel." - ), - value_type_message=_custom_required_format_message( - "Discord webhook URL", - "https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks", - ), - requires_restart_after_changed=False, - required=False, - default=None, - ), - "discord:bot-token": ConfigSettingHelp( - description=( - "The Discord token for the bot you created " - "(available on your bot page in the developer portal: )." - ), - value_type_message=_custom_required_format_message( - "Discord bot token", - "https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts", - ), - requires_restart_after_changed=True, - required=True, - default=None, - ), - "discord:main-guild-id": ConfigSettingHelp( - description="The ID of your community group's main Discord guild.", - value_type_message=_custom_required_format_message( - "Discord guild ID", - "https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id", - ), - requires_restart_after_changed=True, - required=True, - default=None, - ), - "community-group:full-name": ConfigSettingHelp( - description=( - "The full name of your community group, do **NOT** use an abbreviation.\n" - "This is substituted into many error/welcome messages " - "sent into your Discord guild, by **`@TeX-Bot`**.\n" - "If this is not set the group-full-name will be retrieved " - "from the name of your group's Discord guild." - ), - requires_restart_after_changed=False, - value_type_message=None, - required=False, - default=None, - ), - "community-group:short-name": ConfigSettingHelp( - description=( - "The short colloquial name of your community group, " - "it is recommended that you set this to be an abbreviation of your group's name.\n" - "If this is not set the group-short-name will be determined " - "from your group's full name." - ), - requires_restart_after_changed=False, - value_type_message=None, - required=False, - default=None, - ), - "community-group:links:purchase-membership": ConfigSettingHelp( - description=( - "The link to the page where guests can purchase a full membership " - "to join your community group." - ), - requires_restart_after_changed=False, - value_type_message=_custom_required_format_message("URL"), - required=False, - default=None, - ), - "community-group:links:membership-perks": ConfigSettingHelp( - description=( - "The link to the page where guests can find out information " - "about the perks that they will receive " - "once they purchase a membership to your community group." - ), - requires_restart_after_changed=False, - value_type_message=_custom_required_format_message("URL"), - required=False, - default=None, - ), - "community-group:links:moderation-document": ConfigSettingHelp( - description="The link to your group's Discord guild moderation document.", - value_type_message=_custom_required_format_message("URL"), - requires_restart_after_changed=False, - required=True, - default=None, - ), - "community-group:members-list:url": ConfigSettingHelp( - description=( - "The URL to retrieve the list of IDs of people that have purchased a membership " - "to your community group.\n" - "Ensure that all members are visible without pagination, " - "(for example, " - "if your members-list is found on the UoB Guild of Students website, " - 'ensure the URL includes the "sort by groups" option).' - ), - requires_restart_after_changed=False, - value_type_message=_custom_required_format_message("URL"), - required=True, - default=None, - ), - "community-group:members-list:auth-session-cookie": ConfigSettingHelp( - description=( - "The members-list authentication session cookie.\n" - "If your group's members-list is stored at a URL that requires authentication, " - "this session cookie should authenticate **`@TeX-Bot`** " - "to view your group's members-list, " - "as if it were logged in to the website as a Committee member.\n" - "If your members-list is found on the UoB Guild of Students website, " - "this can be extracted from your web-browser: " - "after manually logging in to view your members-list, " - "it will probably be listed as a cookie named `.ASPXAUTH`." - ), - requires_restart_after_changed=False, - value_type_message=None, - required=True, - default=None, - ), - "community-group:members-list:id-format": ConfigSettingHelp( - description=( - "The format that IDs are stored in within your members-list.\n" - "Remember to double escape `\\` characters where necessary." - ), - value_type_message=_custom_required_format_message( - "regex matcher string", - ), - requires_restart_after_changed=False, - required=False, - default=DEFAULT_MEMBERS_LIST_ID_FORMAT, - ), - "commands:ping:easter-egg-probability": ConfigSettingHelp( - description=( - "The probability that the more rare ping command response will be sent " - "instead of the normal one." - ), - value_type_message=_custom_required_format_message( - "float, inclusively between 1 & 0", - ), - requires_restart_after_changed=False, - required=False, - default=str(DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY), - ), - "commands:stats:lookback-days": ConfigSettingHelp( - description=( - "The number of days to look over messages sent, to generate statistics data." - ), - value_type_message=_custom_required_format_message( - "float representing the number of days to look back through", - ), - requires_restart_after_changed=False, - required=False, - default=str(DEFAULT_STATS_COMMAND_LOOKBACK_DAYS), - ), - "commands:stats:displayed-roles": ConfigSettingHelp( - description=( - "The names of the roles to gather statistics about, " - "to display in bar chart graphs." - ), - value_type_message=_custom_required_format_message( - "comma seperated list of strings of role names", - ), - requires_restart_after_changed=False, - required=False, - default=",".join(DEFAULT_STATS_COMMAND_DISPLAYED_ROLES), - ), - "commands:strike:timeout-duration": ConfigSettingHelp( - description=( - "The amount of time to timeout a user when using the **`/strike`** command." - ), - value_type_message=_custom_required_format_message( - ( - "string of the seconds, minutes, hours, days or weeks " - "to timeout a user (format: `smhdw`)" - ), - ), - requires_restart_after_changed=False, - required=False, - default=DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, - ), - "commands:strike:performed-manually-warning-location": ConfigSettingHelp( - description=( - "The name of the channel, that warning messages will be sent to " - "when a committee-member manually applies a moderation action " - "(instead of using the `/strike` command).\n" - "This can be the name of **ANY** Discord channel " - "(so the offending person *will* be able to see these messages " - "if a public channel is chosen)." - ), - value_type_message=_custom_required_format_message( - ( - "name of a Discord channel in your group's Discord guild, " - "or the value `DM` " - "(which indicates that the messages will be sent " - "in the committee-member's DMs)" - ), - ), - requires_restart_after_changed=False, - required=False, - default=DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, - ), - "messages-locale-code": ConfigSettingHelp( - description=( - "The locale code used to select the language response messages will be given in." - ), - value_type_message=_selectable_required_format_message( - MESSAGES_LOCALE_CODES, - ), - requires_restart_after_changed=False, - required=False, - default=DEFAULT_MESSAGE_LOCALE_CODE, - ), - "reminders:send-introduction-reminders:enabled": ConfigSettingHelp( - description=( - "Whether introduction reminders will be sent to Discord members " - "that are not inducted, " - "saying that they need to send an introduction to be allowed access." - ), - value_type_message=_selectable_required_format_message( - ( - str(flag_value).lower() - for flag_value in getattr(SendIntroductionRemindersFlagType, "__args__") # noqa: B009 - ), - ), - requires_restart_after_changed=True, - required=False, - default=str(DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED).lower(), - ), - "reminders:send-introduction-reminders:delay": ConfigSettingHelp( - description=( - "How long to wait after a user joins your guild " - "before sending them the first/only message " - "to remind them to send an introduction.\n" - "Is ignored if `reminders:send-introduction-reminders:enabled` **=** `false`.\n" - "The delay must be longer than or equal to 1 day (in any allowed format)." - ), - value_type_message=_custom_required_format_message( - ( - "string of the seconds, minutes, hours, days or weeks " - "before the first/only reminder is sent " - "(format: `smhdw`)" - ), - ), - requires_restart_after_changed=True, - required=False, - default=DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, - ), - "reminders:send-introduction-reminders:interval": ConfigSettingHelp( - description=( - "The interval of time between sending out reminders " - "to Discord members that are not inducted, " - "saying that they need to send an introduction to be allowed access.\n" - "Is ignored if `reminders:send-introduction-reminders:enabled` **=** `false`." - ), - value_type_message=_custom_required_format_message( - ( - "string of the seconds, minutes, or hours between reminders " - "(format: `smh`)" - ), - ), - requires_restart_after_changed=True, - required=False, - default=DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, - ), - "reminders:send-get-roles-reminders:enabled": ConfigSettingHelp( - description=( - "Whether reminders will be sent to Discord members that have been inducted, " - "saying that they can get opt-in roles. " - "(This message will be only sent once per Discord member)." - ), - value_type_message=_custom_required_format_message( - "boolean value (either `true` or `false`)", - ), - requires_restart_after_changed=True, - required=False, - default=str(DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED).lower(), - ), - "reminders:send-get-roles-reminders:delay": ConfigSettingHelp( - description=( - "How long to wait after a user is inducted " - "before sending them the message to get some opt-in roles.\n" - "Is ignored if `reminders:send-get-roles-reminders:enabled` **=** `false`.\n" - "The delay must be longer than or equal to 1 day (in any allowed format)." - ), - value_type_message=_custom_required_format_message( - ( - "string of the seconds, minutes, hours, days or weeks " - "before the first/only reminder is sent " - "(format: `smhdw`)" - ), - ), - requires_restart_after_changed=True, - required=False, - default=DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, - ), - "reminders:send-get-roles-reminders:interval": ConfigSettingHelp( - description=( - "The interval of time between sending out reminders " - "to Discord members that have been inducted, " - "saying that they can get opt-in roles. " - "(This message will be only sent once, " - "the interval is just how often to check for new guests).\n" - "Is ignored if `reminders:send-get-roles-reminders:enabled` **=** `false`." - ), - value_type_message=_custom_required_format_message( - ( - "string of the seconds, minutes, or hours between reminders " - "(format: `smh`)" - ), - ), - requires_restart_after_changed=True, - required=False, - default=DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, - ), - "check-if-config-changed-interval": ConfigSettingHelp( - description=( - "The interval of time between checking whether the config values, " - "defined in the settings file, have changed." - ), - value_type_message=_custom_required_format_message( - ( - "string of the seconds or minutes between checks " - "(format: `sm`)" - ), - ), - requires_restart_after_changed=True, - required=False, - default=DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL, - ), -} diff --git a/db/_settings.py b/db/_settings.py index 338e3b96b..561da8c66 100644 --- a/db/_settings.py +++ b/db/_settings.py @@ -37,7 +37,7 @@ from config import settings # SECURITY WARNING: keep the secret key used in production secret! - SECRET_KEY = settings.DISCORD_BOT_TOKEN + SECRET_KEY = settings.discord.bot_token.get_secret_value() # Application Definition diff --git a/exceptions/__init__.py b/exceptions/__init__.py index 1ff13bdfa..83acf460c 100644 --- a/exceptions/__init__.py +++ b/exceptions/__init__.py @@ -5,6 +5,7 @@ from .committee_actions import InvalidActionDescriptionError, InvalidActionTargetError from .config_changes import ( ChangingSettingWithRequiredSiblingError, + ImproperlyConfiguredError, RestartRequiredDueToConfigChange, ) from .does_not_exist import ( diff --git a/exceptions/config_changes.py b/exceptions/config_changes.py index 97abe11be..1f38ff71e 100644 --- a/exceptions/config_changes.py +++ b/exceptions/config_changes.py @@ -1,22 +1,32 @@ """Custom exception classes related to configuration changes.""" -from collections.abc import Sequence +from typing import TYPE_CHECKING, override -__all__: Sequence[str] = ( +from typed_classproperties import classproperty + +from .base import BaseTeXBotError + +if TYPE_CHECKING: + from collections.abc import Sequence + from collections.abc import Set as AbstractSet + +__all__: "Sequence[str]" = ( "ChangingSettingWithRequiredSiblingError", + "ImproperlyConfiguredError", "RestartRequiredDueToConfigChange", ) -from collections.abc import Set -from typing import override - -from typed_classproperties import classproperty +class ImproperlyConfiguredError(BaseTeXBotError, Exception): + """Exception class to raise when a configuration value is not correctly provided.""" -from .base import BaseTeXBotError + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + return "One or more provided configuration values are invalid." -class RestartRequiredDueToConfigChange(BaseTeXBotError, Exception): +class RestartRequiredDueToConfigChange(BaseTeXBotError, Exception): # noqa: N818 """Exception class to raise when a restart is required to apply config changes.""" @classproperty @@ -25,11 +35,11 @@ def DEFAULT_MESSAGE(cls) -> str: return "TeX-Bot requires a restart to apply configuration changes." @override - def __init__(self, message: str | None = None, changed_settings: Set[str] | None = None) -> None: # noqa: E501 + def __init__( + self, message: str | None = None, changed_settings: "AbstractSet[str] | None" = None + ) -> None: """Initialise an Exception to apply configuration changes.""" - self.changed_settings: Set[str] | None = ( - changed_settings if changed_settings else set() - ) + self.changed_settings: AbstractSet[str] = changed_settings or set() super().__init__(message) @@ -37,26 +47,27 @@ def __init__(self, message: str | None = None, changed_settings: Set[str] | None class ChangingSettingWithRequiredSiblingError(BaseTeXBotError, ValueError): """Exception class for when a setting cannot be changed because of required siblings.""" - # noinspection PyMethodParameters,PyPep8Naming @classproperty @override def DEFAULT_MESSAGE(cls) -> str: - """The message to be displayed alongside this exception class if none is provided.""" return ( "The given setting cannot be changed " "because it has one or more required sibling settings that must be set first." ) @override - def __init__(self, message: str | None = None, config_setting_name: str | None = None) -> None: # noqa: E501 + def __init__( + self, message: str | None = None, config_setting_name: str | None = None + ) -> None: + """Initialise an Exception for changing a setting with unset required siblings.""" self.config_setting_name: str | None = config_setting_name super().__init__( message or ( - f"Cannot assign value to config setting '{config_setting_name}' " - f"because it has one or more required sibling settings that must be set first." + f"Cannot assign a value to config setting {config_setting_name!r} because " + f"it has one or more required sibling settings that must be set first." if config_setting_name - else message + else None ) ) diff --git a/main.py b/main.py index 548f1e3cd..2f5f1dd50 100755 --- a/main.py +++ b/main.py @@ -33,7 +33,7 @@ def _run_bot() -> "NoReturn": # NOTE: See https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/261 - bot.run(settings["DISCORD_BOT_TOKEN"]) + bot.run(settings.discord.bot_token.get_secret_value()) raise SystemExit(0 if bot.EXIT_WAS_DUE_TO_KILL_COMMAND else 1) diff --git a/pyproject.toml b/pyproject.toml index e7af695f4..6601dbd1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,6 @@ dev = [ ] lint-format = ["pymarkdownlnt>=0.9.28", "ruff>=0.12"] main = [ - "aiopath>=0.7.7", - "anyio>=4.13.0", "asyncstdlib>=3.13", "audioop-lts; python_version > '3.12'", "beautifulsoup4>=4.12", @@ -28,7 +26,6 @@ main = [ "python-dotenv>=1.0", "python-logging-discord-handler>=0.1", "ruamel-yaml>=0.19", - "strictyaml>=1.7.3", "typed_classproperties>=1.2", "validators>=0.34" ] @@ -215,7 +212,6 @@ extend-ignore-names = ["BROKEN_*_MESSAGE", "INVALID_*_MESSAGE", "NO_*_MESSAGE"] "stubs/discord/**/*.pyi" = ["F403"] "stubs/discord/commands/__init__.pyi" = ["F405"] "tests/**/test_*.py" = ["S101"] -"config/_settings/_yaml/__init__.py" = ["E501"] [tool.ruff.lint.pycodestyle] ignore-overlong-task-comments = true diff --git a/stubs/strictyaml/__init__.pyi b/stubs/strictyaml/__init__.pyi deleted file mode 100644 index cb9f230cf..000000000 --- a/stubs/strictyaml/__init__.pyi +++ /dev/null @@ -1,49 +0,0 @@ -from typing import override - -from .exceptions import StrictYAMLError -from .yamllocation import YAMLChunk - -class YAML: ... - -class Validator: - def should_be_string(self, data: object, message: str) -> None: ... - def to_yaml(self, data: object) -> object: ... - -class MapValidator(Validator): - _validator_dict: dict[str, Validator] - -class ScalarValidator(Validator): - def validate_scalar(self, chunk: YAMLChunk) -> object: ... - -class Map(MapValidator): - def __init__( - self, validator_dict: dict[object, object], key_validator: Validator | None = ... - ) -> None: ... - -class Float(ScalarValidator): - @override - def validate_scalar(self, chunk: YAMLChunk) -> float: ... - -class Int(ScalarValidator): - @override - def validate_scalar(self, chunk: YAMLChunk) -> int: ... - -class Bool(ScalarValidator): - @override - def validate_scalar(self, chunk: YAMLChunk) -> bool: ... - -class Str(ScalarValidator): ... - -class Url(ScalarValidator): - def __is_absolute_url(self, raw: str) -> bool: ... - -class Regex(ScalarValidator): - def __init__(self, regular_expression: str) -> None: ... - -class SeqValidator(Validator): ... - -class UniqueSeq(SeqValidator): - def __init__(self, item_validator: Validator) -> None: ... - -class Optional: - def __init__(self, key: str, default: object = None, drop_if_none: bool = True) -> None: ... diff --git a/stubs/strictyaml/constants.pyi b/stubs/strictyaml/constants.pyi deleted file mode 100644 index 2cfc3140f..000000000 --- a/stubs/strictyaml/constants.pyi +++ /dev/null @@ -1,3 +0,0 @@ -BOOL_VALUES: list[str] -TRUE_VALUES: list[str] -FALSE_VALUES: list[str] diff --git a/stubs/strictyaml/exceptions.pyi b/stubs/strictyaml/exceptions.pyi deleted file mode 100644 index 7800fa9cc..000000000 --- a/stubs/strictyaml/exceptions.pyi +++ /dev/null @@ -1,4 +0,0 @@ -class YAMLSerializationError(StrictYAMLError): ... -class StrictYAMLError(MarkedYAMLError): ... -class MarkedYAMLError(YAMLError): ... -class YAMLError(Exception): ... diff --git a/stubs/strictyaml/utils.pyi b/stubs/strictyaml/utils.pyi deleted file mode 100644 index cde78fd01..000000000 --- a/stubs/strictyaml/utils.pyi +++ /dev/null @@ -1,4 +0,0 @@ -def is_string(value: object) -> bool: ... -def is_integer(value: str) -> bool: ... -def is_decimal(value: object) -> bool: ... -def has_number_type(value: object) -> bool: ... diff --git a/stubs/strictyaml/yamllocation.pyi b/stubs/strictyaml/yamllocation.pyi deleted file mode 100644 index cdc34659d..000000000 --- a/stubs/strictyaml/yamllocation.pyi +++ /dev/null @@ -1,3 +0,0 @@ -class YAMLChunk: - contents: str - def expecting_but_found(self, expecting: str, found: str = ...) -> None: ... diff --git a/tex-bot-deployment.example.yaml b/tex-bot-deployment.example.yaml new file mode 100644 index 000000000..d7a36f44b --- /dev/null +++ b/tex-bot-deployment.example.yaml @@ -0,0 +1,83 @@ +--- +# An example TeX-Bot deployment configuration file. +# +# Copy this file to `tex-bot-deployment.yaml` and fill in the required values. +# (Set the `TEX_BOT_CONFIG_PATH` environment variable to keep it somewhere else.) +# +# Comments you add to your own file are preserved when TeX-Bot rewrites it, +# so it is safe to annotate your configuration and still use the `/config` command. +# +# Durations are written largest-unit-first, in the format +# `dhms`, so `1h30m` and `2d` are both valid. + +discord: + # REQUIRED. From your bot's page within the Discord developer portal: + # + bot-token: "" + # REQUIRED. The ID of your community group's main Discord guild. + main-guild-id: 0 + +community-group: + # Optional. Falls back to the name of your Discord guild. + full-name: "" + # Optional. Falls back to being derived from the full name. + short-name: "" + # Optional. Roles that should only be held by members of your community group. + membership-dependent-roles: [] + + links: + purchase-membership: "" + membership-perks: "" + moderation-policy: "" + # Optional. Used in place of an invite link generated by TeX-Bot. + custom-discord-invite-link: "" + + msl: + organisation-id: "" + # Your members-list authentication session cookie. On the UoB Guild of + # Students website this is the cookie named `.ASPXAUTH`. + auth-cookie: "" + auto-cookie-checking: + enabled: false + interval: 10m + +# Every section below is optional; the values shown are the defaults. + +logging: + console: + log-level: INFO + # Omit this section entirely to disable Discord log-channel logging. + # discord-channel: + # webhook-url: https://discord.com/api/webhooks/... + # log-level: WARNING + discord-api: + enabled: false + log-level: INFO + file-name: discord.log + +commands: + ping: + easter-egg-probability: 0.01 + stats: + lookback-days: 30 + displayed-roles: + - Committee + - Member + - Guest + strike: + performed-manually-warning-location: DM + timeout-duration: 1d + reported-message-destination-channel: discord + +reminders: + send-introduction-reminders: + # One of `once`, `interval` or `false`. + enabled: once + delay: 1d16h + interval: 6h + send-get-roles-reminders: + enabled: true + delay: 1d16h + interval: 6h + +auto-add-committee-to-threads: true diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index e56b7e072..9a7bbf305 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -36,10 +36,14 @@ } BASE_SU_PLATFORM_WEB_COOKIES: "Mapping[str, str]" = { - ".AspNet.SharedCookie": settings["SU_PLATFORM_ACCESS_COOKIE"], + ".AspNet.SharedCookie": ( + settings.community_group.msl.auth_cookie.get_secret_value() + if settings.community_group.msl.auth_cookie is not None + else "" + ), } -MEMBERS_LIST_URL: "Final[str]" = f"https://guildofstudents.com/organisation/memberlist/{settings['ORGANISATION_ID']}/?sort=groups" +MEMBERS_LIST_URL: "Final[str]" = f"https://guildofstudents.com/organisation/memberlist/{settings.community_group.msl.organisation_id}/?sort=groups" _membership_list_cache: set[int] = set() diff --git a/utils/tex_bot.py b/utils/tex_bot.py index 714eecf37..1a9c46594 100644 --- a/utils/tex_bot.py +++ b/utils/tex_bot.py @@ -90,11 +90,10 @@ def main_guild(self) -> discord.Guild: Raises `GuildDoesNotExist` if the given ID does not link to a valid Discord guild. """ MAIN_GUILD_EXISTS: Final[bool] = bool( - self._main_guild - and self._check_guild_accessible(settings["_DISCORD_MAIN_GUILD_ID"]) + self._main_guild and self._check_guild_accessible(settings.discord.main_guild_id) ) if not MAIN_GUILD_EXISTS: - raise GuildDoesNotExistError(guild_id=settings["_DISCORD_MAIN_GUILD_ID"]) + raise GuildDoesNotExistError(guild_id=settings.discord.main_guild_id) return self._main_guild # type: ignore[return-value] @@ -290,7 +289,7 @@ def group_full_name(self) -> str: The group-full-name is either retrieved from the provided environment variable or automatically identified from the name of your group's Discord guild. """ - return settings["_GROUP_FULL_NAME"] or ( + return settings.community_group.full_name or ( "The Computer Science Society" if ( "computer science society" in self.main_guild.name.lower() @@ -309,7 +308,7 @@ def group_short_name(self) -> str: """ return ( ( - settings["_GROUP_SHORT_NAME"] + settings.community_group.short_name or ( "CSS" if ( @@ -497,7 +496,7 @@ async def fetch_log_channel(self) -> discord.TextChannel: If no DISCORD_LOG_CHANNEL_WEBHOOK_URL is specified, a ValueError exception will be raised. """ - if not settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"]: + if settings.logging.discord_channel is None: NO_LOG_CHANNEL_MESSAGE: Final[str] = ( "Cannot fetch log channel, " "when no DISCORD_LOG_CHANNEL_WEBHOOK_URL has been set." @@ -507,7 +506,7 @@ async def fetch_log_channel(self) -> discord.TextChannel: session: aiohttp.ClientSession async with aiohttp.ClientSession() as session: partial_webhook: Webhook = Webhook.from_url( - settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"], session=session + str(settings.logging.discord_channel.webhook_url), session=session ) full_webhook: Webhook = await partial_webhook.fetch() diff --git a/uv.lock b/uv.lock index ab2cb101a..254a6a336 100644 --- a/uv.lock +++ b/uv.lock @@ -2,18 +2,6 @@ version = 1 revision = 3 requires-python = "==3.13.*" -[[package]] -name = "aiofile" -version = "3.12.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "caio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/31/edb06aabd8f8f0b56d659f30800795f40b93cba96be946ce179f6931e3a5/aiofile-3.12.3.tar.gz", hash = "sha256:caa6aa746b5e47e2165f7abd741b6415e49cf4d44fddc0f61844612cc3924d41", size = 21600, upload-time = "2026-08-04T22:59:27.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" }, -] - [[package]] name = "aiohappyeyeballs" version = "2.7.1" @@ -63,19 +51,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, ] -[[package]] -name = "aiopath" -version = "0.7.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiofile" }, - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a4/6f/b377e3eb293feb57e457b33961004cc3716794ef97428d2ae2a326599b1c/aiopath-0.7.7.tar.gz", hash = "sha256:ad4b9d09ae08ddf6d39dd06e7b0a353939e89528da571c0cd4f3fe071aefad4f", size = 16925, upload-time = "2023-10-16T22:37:11.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/df/6a3826363e4848ffa8972410ab5234d3802597ae92f4a6397600149112c7/aiopath-0.7.7-py2.py3-none-any.whl", hash = "sha256:cd5d18de8ede167e1db659f02ee448fe085f923cb8e194407ccc568bffc4fe4e", size = 12200, upload-time = "2023-10-16T22:37:09.595Z" }, -] - [[package]] name = "aiosignal" version = "1.4.0" @@ -97,18 +72,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] -[[package]] -name = "anyio" -version = "4.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, -] - [[package]] name = "application-file-scanner" version = "0.6.4" @@ -242,21 +205,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] -[[package]] -name = "caio" -version = "0.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/c8/82b3c760141a1076408164b03e8789b51809add6aecd48aa9d7651cf6b59/caio-0.12.2.tar.gz", hash = "sha256:87a67c0dccc60e432888bd532ec504b66e124a5d8b391aab894583b55abd39ea", size = 80927, upload-time = "2026-08-04T14:43:33.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/9b/31f0b49a2542ffa2f9d6140267e2b568e722a1feeb05cfbffea97666c62b/caio-0.12.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:40ebea9ebe3a3a66ae85fa00d4112d163654a33c82dcf9b26a99f7d30de13317", size = 84656, upload-time = "2026-08-04T14:43:10.513Z" }, - { url = "https://files.pythonhosted.org/packages/99/bc/62568d688af9712a34fe3f958d7a98c53bb2017e263260cd5deae67a90e9/caio-0.12.2-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:6003ec389a68d5ec8f089df82b2dc8915293dd630a4d11322d7e3455045981fd", size = 198443, upload-time = "2026-08-04T14:43:11.767Z" }, - { url = "https://files.pythonhosted.org/packages/a3/e4/5ed627860285612e5307f06c109913c5918c947fbc223b55599e484c64b0/caio-0.12.2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:eee9376d0e2af25b6defc5bce39f6efa90521c803aaf12eba931bd898a397cfc", size = 196356, upload-time = "2026-08-04T14:43:13.206Z" }, - { url = "https://files.pythonhosted.org/packages/81/e2/2a8cfc6ba3ef3f19e7c778e9fb6f98600f0971cca78bbdfc23a413a66349/caio-0.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:78e3ccafc98e009fcb00a97ad441585551e52c0ae7ecc50427a3ccd9b11502fd", size = 195893, upload-time = "2026-08-04T14:43:14.649Z" }, - { url = "https://files.pythonhosted.org/packages/d1/87/77c40fb2301d0b5bb27c2e79ae42fce718ed75396d5fe3e1c09d8e1400b1/caio-0.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f2355db8917f5a0f3638bf332fe0d87549c80e978fca01db84a8a14b9df56a05", size = 195969, upload-time = "2026-08-04T14:43:15.946Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b5/0ceca97eb546fe6bbace3399c8b11dfc503efcc7509d708a7a3f09ab50e9/caio-0.12.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8054cba5e7ee623bea34946e2b59eb7c7c2be8872d0a5d12215d6ff564938d5f", size = 78621, upload-time = "2026-08-04T14:43:17.316Z" }, - { url = "https://files.pythonhosted.org/packages/61/8a/71b0144f783468ba9f1bbf8a2f8e45c7d85ae31ec192f10650aa46f31702/caio-0.12.2-py3-none-any.whl", hash = "sha256:5233e797c9fe2b541914b1bc2e2df82677e2206b537e44e252188f3c2cbb0ea9", size = 62548, upload-time = "2026-08-04T14:43:32.394Z" }, -] - [[package]] name = "certifi" version = "2026.6.17" @@ -1157,18 +1105,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] -[[package]] -name = "strictyaml" -version = "1.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/08/efd28d49162ce89c2ad61a88bd80e11fb77bc9f6c145402589112d38f8af/strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407", size = 115206, upload-time = "2023-03-10T12:50:27.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size = 123917, upload-time = "2023-03-10T12:50:17.242Z" }, -] - [[package]] name = "tex-bot-py-v2" version = "0.1.0" @@ -1190,8 +1126,6 @@ lint-format = [ { name = "ruff" }, ] main = [ - { name = "aiopath" }, - { name = "anyio" }, { name = "asyncstdlib" }, { name = "audioop-lts" }, { name = "beautifulsoup4" }, @@ -1206,7 +1140,6 @@ main = [ { name = "python-dotenv" }, { name = "python-logging-discord-handler" }, { name = "ruamel-yaml" }, - { name = "strictyaml" }, { name = "typed-classproperties" }, { name = "validators" }, ] @@ -1241,8 +1174,6 @@ lint-format = [ { name = "ruff", specifier = ">=0.12" }, ] main = [ - { name = "aiopath", specifier = ">=0.7.7" }, - { name = "anyio", specifier = ">=4.13.0" }, { name = "asyncstdlib", specifier = ">=3.13" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, { name = "beautifulsoup4", specifier = ">=4.12" }, @@ -1257,7 +1188,6 @@ main = [ { name = "python-dotenv", specifier = ">=1.0" }, { name = "python-logging-discord-handler", specifier = ">=0.1" }, { name = "ruamel-yaml", specifier = ">=0.19" }, - { name = "strictyaml", specifier = ">=1.7.3" }, { name = "typed-classproperties", specifier = ">=1.2" }, { name = "validators", specifier = ">=0.34" }, ] From 1a926e6a5318c559ae537a8bfc29078e0b03dc6f Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:54:59 +0100 Subject: [PATCH 17/33] Fix container image build after the configuration cutover The image build has been failing since `config.py` was replaced: the Dockerfile still copied that file, and never copied the `config/` package that replaced it. Also excludes the deployment configuration file from the build context, so that a local configuration holding a real bot token cannot be captured in an image layer, along with the temporary file written beside it whilst it is being rewritten. --- .dockerignore | 3 +++ Dockerfile | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index b5f57d16e..14db91990 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,9 @@ CONTRIBUTING.md Dockerfile *.env !.env +tex-bot-deployment.yaml +tex-bot-deployment.*.yaml +tex-bot-deployment.yaml.*.tmp uv.lock pyproject.toml tests diff --git a/Dockerfile b/Dockerfile index 7e82bf46c..9e635c2c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-install-project --no-group dev COPY LICENSE /app/ -COPY config.py main.py messages.json /app/ +COPY main.py messages.json /app/ +COPY config/ /app/config/ COPY exceptions/ /app/exceptions/ COPY utils/ /app/utils/ COPY db/ /app/db/ From 2c60e8feb8d76ed6c705257af702b9ef3d2943a6 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:06:39 +0100 Subject: [PATCH 18/33] Keep configuration writable when it is mounted into a container Replacing the configuration file by renaming a temporary file over it is atomic, but it is rejected when the file has been mounted into a container individually, because a rename cannot replace a mount point. That is the obvious way to supply the file to the published container image, so writing a setting would have failed for most deployments. Where the rename is rejected, the file is now written directly instead. Doing so is not atomic, but a torn write is only possible if the process dies during it, whereas being unable to save at all would have been certain. Also gives the image a dedicated `/app/data` directory for the configuration file, kept apart from the application code so it can be mounted as a directory rather than as an individual file. Mounting the directory avoids the rejected rename entirely, keeps the atomic path as the one normally taken, and means a named volume inherits ownership from the image and is writable without any further setup. `TEX_BOT_CONFIG_PATH` points there by default within the image; running outside a container is unaffected. --- Dockerfile | 10 +++++++++- config/_document.py | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9e635c2c2..09f283bfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,13 @@ COPY utils/ /app/utils/ COPY db/ /app/db/ COPY cogs/ /app/cogs/ +# NOTE: The deployment configuration is kept in its own directory, separate from any +# application code, so that it can be mounted as a directory. Mounting the directory +# (rather than the configuration file individually) allows TeX-Bot to rewrite the file +# in place when the `/config` command changes a setting: replacing an individually +# mounted file is rejected, because a rename cannot replace a mount point. +RUN mkdir --parents /app/data + FROM python:3.13-slim-trixie RUN groupadd --system --gid 999 nonroot && useradd --system --gid 999 --uid 999 --create-home nonroot @@ -30,7 +37,8 @@ LABEL org.opencontainers.image.licenses=Apache-2.0 COPY --from=builder --chown=nonroot:nonroot /app /app -ENV LANG=C.UTF-8 PATH="/app/.venv/bin:$PATH" +ENV LANG=C.UTF-8 PATH="/app/.venv/bin:$PATH" \ + TEX_BOT_CONFIG_PATH=/app/data/tex-bot-deployment.yaml WORKDIR /app diff --git a/config/_document.py b/config/_document.py index a5d325292..2ad612fac 100644 --- a/config/_document.py +++ b/config/_document.py @@ -10,6 +10,7 @@ """ import io +import logging import os from pathlib import Path from typing import TYPE_CHECKING @@ -20,6 +21,7 @@ if TYPE_CHECKING: from collections.abc import Iterator, Sequence + from logging import Logger from typing import Final, Self from pydantic import ValidationError @@ -34,6 +36,8 @@ ) +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + PROJECT_ROOT: "Final[Path]" = Path(__file__).parent.parent.resolve() DEFAULT_SETTINGS_FILE_NAME: "Final[str]" = "tex-bot-deployment.yaml" @@ -180,11 +184,17 @@ def dump(self) -> str: def write(self) -> None: """ - Persist this document to disk, atomically. + Persist this document to disk, atomically where the filesystem allows it. + + The serialised document is written to a temporary file alongside the destination + and then moved into place, so that a failure partway through writing cannot leave + a truncated configuration file behind. - The serialised document is written to a temporary file alongside the destination, - then moved into place, so that a failure partway through writing cannot leave a - truncated configuration file behind. + Where that move is rejected, the document is written directly instead. This + happens when the configuration file is an individually mounted file within a + container, because a rename cannot replace a mount point. Writing directly is not + atomic, but it is the only option available in that case; mounting the directory + holding the configuration file, rather than the file itself, avoids it entirely. """ NEW_FILE_CONTENTS: Final[str] = self.dump() @@ -194,9 +204,23 @@ def write(self) -> None: try: temporary_file_path.write_text(NEW_FILE_CONTENTS, encoding="utf-8") - # NOTE: `os.replace()` is atomic where the source & destination are located upon - # the same filesystem, which writing the temporary file alongside guarantees. - os.replace(temporary_file_path, self._file_path) # noqa: PTH105 + + replace_error: OSError + try: + # NOTE: `os.replace()` is atomic where the source & destination are located + # upon the same filesystem, which writing the temporary file alongside the + # destination guarantees. + os.replace(temporary_file_path, self._file_path) # noqa: PTH105 + except OSError as replace_error: + logger.debug( + ( + "Could not atomically replace %s (%s); " + "falling back to writing it in place." + ), + self._file_path, + replace_error.strerror or replace_error, + ) + self._file_path.write_text(NEW_FILE_CONTENTS, encoding="utf-8") finally: temporary_file_path.unlink(missing_ok=True) From 38ab46d818a34d57d29a556ca2b169483939df81 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:20:49 +0100 Subject: [PATCH 19/33] Add a committee-only "/config reload" command Adds the `/config` command group, with a `reload` subcommand that reads the deployment configuration file again and applies every change that can be applied while TeX-Bot is running. Both are restricted to committee members. A reload that fails, because the file cannot be read or contains invalid settings, changes nothing at all and reports why, quoting the file and line responsible. Most settings take effect immediately, because they are read from the settings accessor at the point they are used. The exception is a background task's interval, which is captured when its cog class is defined, so cogs are now offered each reload through an `on_config_reloaded` hook and re-apply anything they hold a copy of. The three task cogs use it to change their interval, and to start or stop themselves, without a restart. Only three settings now require a restart, down from eight: - `discord:bot-token` and `discord:main-guild-id`, which are used to establish the connection and to populate the shortcut accessors during startup. - `reminders:send-introduction-reminders:enabled`, because setting it to `interval` also clears the record of which members have already been sent a one-off reminder. That is a database side effect which should not happen implicitly during a reload, so it is deliberately left to a restart. Where a changed setting needs a restart, the reload still succeeds and applies everything else, and the response says which settings are waiting. Whether a setting requires a restart is now read from the schema, rather than being tracked separately, so the two cannot disagree. --- cogs/__init__.py | 2 + cogs/check_su_platform_authorisation.py | 14 ++- cogs/config.py | 157 ++++++++++++++++++++++++ cogs/send_get_roles_reminders.py | 15 ++- cogs/send_introduction_reminders.py | 22 +++- config/__init__.py | 3 + config/_schema.py | 74 +++++++++-- utils/__init__.py | 3 + utils/config_reload.py | 92 ++++++++++++++ utils/tex_bot_base_cog.py | 13 ++ 10 files changed, 385 insertions(+), 10 deletions(-) create mode 100644 cogs/config.py create mode 100644 utils/config_reload.py diff --git a/cogs/__init__.py b/cogs/__init__.py index 0c74de61e..22ea428e4 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -23,6 +23,7 @@ CommitteeActionsTrackingContextCommandCog, CommitteeActionsTrackingSlashCommandsCog, ) +from .config import ConfigCommandsCog from .delete_all import DeleteAllCommandsCog from .edit_message import EditMessageCommandCog from .everest import EverestCommandCog @@ -103,6 +104,7 @@ def setup(bot: "TeXBot") -> None: CommitteeActionsTrackingSlashCommandsCog, CommitteeActionsTrackingContextCommandCog, CommitteeHandoverCommandCog, + ConfigCommandsCog, DeleteAllCommandsCog, EditMessageCommandCog, EnsureMembersInductedCommandCog, diff --git a/cogs/check_su_platform_authorisation.py b/cogs/check_su_platform_authorisation.py index 3b8124333..32cc28559 100644 --- a/cogs/check_su_platform_authorisation.py +++ b/cogs/check_su_platform_authorisation.py @@ -9,7 +9,7 @@ from discord.ext import tasks from config import settings -from utils import CommandChecks, TeXBotBaseCog +from utils import CommandChecks, TeXBotBaseCog, reapply_task_settings from utils.error_capture_decorators import ( capture_guild_does_not_exist_error, ) @@ -225,6 +225,18 @@ def cog_unload(self) -> None: """ self.su_platform_access_cookie_check_task.cancel() + @override + async def on_config_reloaded(self, changed_settings: "AbstractSet[str]") -> None: + """Apply any change to whether this task runs, or how often it runs.""" + reapply_task_settings( + self.su_platform_access_cookie_check_task, + changed_settings=changed_settings, + enabled=settings.community_group.msl.auto_cookie_checking.enabled, + enabled_setting_name="community-group:msl:auto-cookie-checking:enabled", + interval=settings.community_group.msl.auto_cookie_checking.interval, + interval_setting_name="community-group:msl:auto-cookie-checking:interval", + ) + @tasks.loop( seconds=settings.community_group.msl.auto_cookie_checking.interval.total_seconds() ) diff --git a/cogs/config.py b/cogs/config.py new file mode 100644 index 000000000..b67136806 --- /dev/null +++ b/cogs/config.py @@ -0,0 +1,157 @@ +"""Contains cog classes for viewing & changing TeX-Bot's configuration at run-time.""" + +import logging +from typing import TYPE_CHECKING + +import discord + +import config +from config import SettingsValidationError, get_settings_metadata +from utils import CommandChecks, TeXBotBaseCog + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + from collections.abc import Set as AbstractSet + from logging import Logger + from typing import Final + + from config import ConfigSettingMetadata + from utils import TeXBot, TeXBotApplicationContext + + +__all__: "Sequence[str]" = ("ConfigCommandsCog", "reload_config") + + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + +MAXIMUM_LISTED_SETTINGS: "Final[int]" = 20 + + +async def reload_config(bot: "TeXBot") -> "tuple[AbstractSet[str], AbstractSet[str]]": + """ + Reload the configuration file, applying every change that can be applied while running. + + Returns the set of settings key paths that changed, along with the subset of those + that cannot take effect until TeX-Bot is restarted. + + Raises `SettingsValidationError` (or one of the file-reading errors) without applying + anything, if the configuration file cannot be read or contains invalid settings. + """ + CHANGED_SETTINGS: Final[AbstractSet[str]] = config.reload_settings() + + if not CHANGED_SETTINGS: + return CHANGED_SETTINGS, frozenset() + + # NOTE: Every cog is offered the change, so that a cog holding a copy of any setting + # (the interval of a task, for example) can re-apply it to itself. + cog: discord.Cog + for cog in bot.cogs.values(): + if isinstance(cog, TeXBotBaseCog): + await cog.on_config_reloaded(CHANGED_SETTINGS) + + SETTINGS_METADATA: Final[Mapping[str, ConfigSettingMetadata]] = get_settings_metadata() + + RESTART_REQUIRED_SETTINGS: Final[AbstractSet[str]] = frozenset( + changed_setting + for changed_setting in CHANGED_SETTINGS + if changed_setting in SETTINGS_METADATA + and SETTINGS_METADATA[changed_setting].requires_restart + ) + + return CHANGED_SETTINGS, RESTART_REQUIRED_SETTINGS + + +def _format_settings_list(settings_names: "Iterable[str]") -> str: + """Format the given settings key paths into a bulleted list, truncated if very long.""" + SORTED_SETTINGS_NAMES: Final[Sequence[str]] = sorted(settings_names) + + listed_settings_names: Sequence[str] = SORTED_SETTINGS_NAMES[:MAXIMUM_LISTED_SETTINGS] + + formatted_settings_list: str = "\n".join( + f"- `{settings_name}`" for settings_name in listed_settings_names + ) + + REMAINING_COUNT: Final[int] = len(SORTED_SETTINGS_NAMES) - len(listed_settings_names) + if REMAINING_COUNT > 0: + formatted_settings_list += f"\n- _...and {REMAINING_COUNT} more_" + + return formatted_settings_list + + +class ConfigCommandsCog(TeXBotBaseCog): + """Cog class that defines the "/config" command group & its call-back methods.""" + + config: discord.SlashCommandGroup = discord.SlashCommandGroup( + name="config", + description="View & change TeX-Bot's configuration.", + ) + + @config.command( + name="reload", + description="Reload the configuration file, applying any changes made to it.", + ) + @CommandChecks.check_interaction_user_has_committee_role + @CommandChecks.check_interaction_user_in_main_guild + async def reload(self, ctx: "TeXBotApplicationContext") -> None: + """ + Definition & callback response of the "config reload" command. + + Reads the configuration file again, applying every change that can be applied + without restarting TeX-Bot, and reporting any that cannot. + """ + await ctx.defer(ephemeral=True) + + changed_settings: AbstractSet[str] + restart_required_settings: AbstractSet[str] + + configuration_error: Exception + try: + changed_settings, restart_required_settings = await reload_config(self.bot) + except SettingsValidationError as configuration_error: + logger.warning("Configuration reload rejected:\n%s", configuration_error) + await ctx.respond( + ( + ":x: The configuration file was **not** loaded, " + "because it contains invalid settings. " + "No changes have been applied.\n" + f"```\n{configuration_error}\n```" + ), + ephemeral=True, + ) + return + except (OSError, ValueError) as configuration_error: + logger.warning("Configuration reload failed: %s", configuration_error) + await ctx.respond( + ( + ":x: The configuration file could not be read, " + "so no changes have been applied.\n" + f"```\n{configuration_error}\n```" + ), + ephemeral=True, + ) + return + + if not changed_settings: + await ctx.respond( + ":information_source: The configuration file has not changed.", + ephemeral=True, + ) + return + + logger.info("Configuration reloaded: %s setting(s) changed.", len(changed_settings)) + + response_message: str = ( + f":white_check_mark: Reloaded the configuration file. " + f"{len(changed_settings)} setting(s) changed:\n" + f"{_format_settings_list(changed_settings)}" + ) + + if restart_required_settings: + response_message += ( + "\n\n:warning: **TeX-Bot must be restarted " + "before the following take effect:**\n" + f"{_format_settings_list(restart_required_settings)}\n" + "Every other change above has already been applied." + ) + + await ctx.respond(response_message, ephemeral=True) diff --git a/cogs/send_get_roles_reminders.py b/cogs/send_get_roles_reminders.py index 0f29f1bc5..6b8b3d0f5 100644 --- a/cogs/send_get_roles_reminders.py +++ b/cogs/send_get_roles_reminders.py @@ -12,7 +12,7 @@ from config import settings from db.core.models import DiscordMember, SentGetRolesReminderMember from exceptions import GuestRoleDoesNotExistError -from utils import TeXBotBaseCog +from utils import TeXBotBaseCog, reapply_task_settings from utils.error_capture_decorators import ( ErrorCaptureDecorators, capture_guild_does_not_exist_error, @@ -21,6 +21,7 @@ if TYPE_CHECKING: import datetime from collections.abc import Sequence + from collections.abc import Set as AbstractSet from logging import Logger from typing import Final @@ -52,6 +53,18 @@ def cog_unload(self) -> None: """ self.send_get_roles_reminders.cancel() + @override + async def on_config_reloaded(self, changed_settings: "AbstractSet[str]") -> None: + """Apply any change to whether this task runs, or how often it runs.""" + reapply_task_settings( + self.send_get_roles_reminders, + changed_settings=changed_settings, + enabled=settings.reminders.send_get_roles_reminders.enabled, + enabled_setting_name="reminders:send-get-roles-reminders:enabled", + interval=settings.reminders.send_get_roles_reminders.interval, + interval_setting_name="reminders:send-get-roles-reminders:interval", + ) + @tasks.loop(seconds=settings.reminders.send_get_roles_reminders.interval.total_seconds()) @functools.partial( ErrorCaptureDecorators.capture_error_and_close, diff --git a/cogs/send_introduction_reminders.py b/cogs/send_introduction_reminders.py index 5384d33c4..df82cda6c 100644 --- a/cogs/send_introduction_reminders.py +++ b/cogs/send_introduction_reminders.py @@ -19,7 +19,7 @@ SentOneOffIntroductionReminderMember, ) from exceptions import DiscordMemberNotInMainGuildError, GuestRoleDoesNotExistError -from utils import TeXBotBaseCog +from utils import TeXBotBaseCog, reapply_task_settings from utils.error_capture_decorators import ( ErrorCaptureDecorators, capture_guild_does_not_exist_error, @@ -27,6 +27,7 @@ if TYPE_CHECKING: from collections.abc import Sequence + from collections.abc import Set as AbstractSet from logging import Logger from typing import Final @@ -61,6 +62,25 @@ def cog_unload(self) -> None: """ self.send_introduction_reminders.cancel() + @override + async def on_config_reloaded(self, changed_settings: "AbstractSet[str]") -> None: + """ + Apply any change to how often this task runs. + + NOTE: A change to whether this task runs at all is deliberately not applied here. + Enabling it with a value of `interval` also clears the record of which members + have already been sent a one-off reminder, and that should not happen implicitly + during a reload, so that setting requires a restart instead. + """ + reapply_task_settings( + self.send_introduction_reminders, + changed_settings=changed_settings, + enabled=bool(settings.reminders.send_introduction_reminders.enabled), + enabled_setting_name=None, + interval=settings.reminders.send_introduction_reminders.interval, + interval_setting_name="reminders:send-introduction-reminders:interval", + ) + @TeXBotBaseCog.listener() async def on_ready(self) -> None: """Add OptOutIntroductionRemindersView to the bot's list of permanent views.""" diff --git a/config/__init__.py b/config/__init__.py index 690cac3e0..95eb434d1 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -20,6 +20,7 @@ ) from ._logging import apply_logging_settings from ._messages import MessagesAccessor +from ._schema import ConfigSettingMetadata, get_settings_metadata if TYPE_CHECKING: from collections.abc import Sequence @@ -29,12 +30,14 @@ __all__: "Sequence[str]" = ( + "ConfigSettingMetadata", "InvalidSettingsFileError", "SettingsDocument", "SettingsFileNotFoundError", "SettingsNotLoadedError", "SettingsValidationError", "get_settings_file_path", + "get_settings_metadata", "messages", "reload_settings", "run_setup", diff --git a/config/_schema.py b/config/_schema.py index dc896f2eb..4dcb814dd 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -16,7 +16,7 @@ import datetime import re from enum import StrEnum -from typing import TYPE_CHECKING, Annotated, Literal +from typing import TYPE_CHECKING, Annotated, Literal, NamedTuple, get_args from pydantic import ( AfterValidator, @@ -29,13 +29,16 @@ ) if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Iterator, Mapping, Sequence + + from pydantic.fields import FieldInfo __all__: "Sequence[str]" = ( "AutoCookieCheckingSettings", "CommandsSettings", "CommunityGroupSettings", + "ConfigSettingMetadata", "ConsoleLoggingSettings", "DiscordAPILoggingSettings", "DiscordChannelLoggingSettings", @@ -52,6 +55,7 @@ "SettingsSchema", "StatsCommandSettings", "StrikeCommandSettings", + "get_settings_metadata", ) @@ -581,7 +585,7 @@ class SendIntroductionRemindersSettings(_BaseSettingsSchema): # type: ignore[ex "the first/only message reminding them to send an introduction.\n" "Is ignored if `enabled` **=** `false`." ), - json_schema_extra={"requires_restart": True, "secret": False}, + json_schema_extra={"requires_restart": False, "secret": False}, ) interval: TimeDelta = Field( default=datetime.timedelta(hours=6), @@ -590,7 +594,7 @@ class SendIntroductionRemindersSettings(_BaseSettingsSchema): # type: ignore[ex "that are not inducted.\n" "Is ignored unless `enabled` **=** `interval`." ), - json_schema_extra={"requires_restart": True, "secret": False}, + json_schema_extra={"requires_restart": False, "secret": False}, ) @@ -604,7 +608,7 @@ class ReminderSettings(_BaseSettingsSchema): # type: ignore[explicit-any] "saying that they can get opt-in roles. " "(This message will only be sent once per Discord member.)" ), - json_schema_extra={"requires_restart": True, "secret": False}, + json_schema_extra={"requires_restart": False, "secret": False}, ) delay: TimeDelta = Field( default=datetime.timedelta(hours=40), @@ -613,7 +617,7 @@ class ReminderSettings(_BaseSettingsSchema): # type: ignore[explicit-any] "telling them to get some opt-in roles.\n" "Is ignored if `enabled` **=** `false`." ), - json_schema_extra={"requires_restart": True, "secret": False}, + json_schema_extra={"requires_restart": False, "secret": False}, ) interval: TimeDelta = Field( default=datetime.timedelta(hours=6), @@ -622,7 +626,7 @@ class ReminderSettings(_BaseSettingsSchema): # type: ignore[explicit-any] "that should be sent a get-roles reminder.\n" "Is ignored if `enabled` **=** `false`." ), - json_schema_extra={"requires_restart": True, "secret": False}, + json_schema_extra={"requires_restart": False, "secret": False}, ) @@ -651,3 +655,59 @@ class SettingsSchema(_BaseSettingsSchema): # type: ignore[explicit-any] ), json_schema_extra={"requires_restart": False, "secret": False}, ) + + +class ConfigSettingMetadata(NamedTuple): + """The information describing a single configuration setting to a human.""" + + description: str | None + requires_restart: bool + secret: bool + + +def _walk_settings_metadata( + model: type[BaseModel], prefix: str = "" +) -> "Iterator[tuple[str, ConfigSettingMetadata]]": + """Yield the metadata of every individual setting declared within the given model.""" + field_name: str + field: FieldInfo + for field_name, field in model.model_fields.items(): + KEY_PATH: str = f"{prefix}{field_name.replace('_', '-')}" + + # NOTE: A field's annotation may be a union (an optional section, for example), so + # every member of it must be searched to find the nested model it may contain. + nested_model: type[BaseModel] | None = next( + ( + annotation_argument + for annotation_argument in (get_args(field.annotation) or (field.annotation,)) + if isinstance(annotation_argument, type) + and issubclass(annotation_argument, BaseModel) + ), + None, + ) + if nested_model is not None: + yield from _walk_settings_metadata(nested_model, prefix=f"{KEY_PATH}:") + continue + + EXTRA: Mapping[str, object] = ( + field.json_schema_extra if isinstance(field.json_schema_extra, dict) else {} + ) + + yield ( + KEY_PATH, + ConfigSettingMetadata( + description=field.description, + requires_restart=EXTRA.get("requires_restart") is True, + secret=EXTRA.get("secret") is True, + ), + ) + + +def get_settings_metadata() -> "Mapping[str, ConfigSettingMetadata]": + """ + Return the metadata of every configuration setting, keyed by its key path. + + Derived from the schema itself, so that the help text, restart requirements & + secrecy of each setting cannot drift out of step with the settings that exist. + """ + return dict(_walk_settings_metadata(SettingsSchema)) diff --git a/utils/__init__.py b/utils/__init__.py index 48ce5285b..daadd9df4 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -8,6 +8,7 @@ import discord from .command_checks import CommandChecks +from .config_reload import RestartableTask, reapply_task_settings from .message_sender_components import MessageSavingSenderComponent from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot @@ -23,6 +24,7 @@ "AllChannelTypes", "CommandChecks", "MessageSavingSenderComponent", + "RestartableTask", "SuppressTraceback", "TeXBot", "TeXBotApplicationContext", @@ -31,6 +33,7 @@ "generate_invite_url", "is_member_inducted", "is_running_in_async", + "reapply_task_settings", ) diff --git a/utils/config_reload.py b/utils/config_reload.py new file mode 100644 index 000000000..4a0d8b533 --- /dev/null +++ b/utils/config_reload.py @@ -0,0 +1,92 @@ +"""Helpers for re-applying changed configuration settings to running background tasks.""" + +import logging +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + import datetime + from collections.abc import Sequence + from collections.abc import Set as AbstractSet + from logging import Logger + from typing import Final + + +__all__: "Sequence[str]" = ("RestartableTask", "reapply_task_settings") + + +class RestartableTask(Protocol): + """ + The parts of a `discord.ext.tasks.Loop` needed to re-apply its settings. + + NOTE: Declared structurally, rather than referring to `Loop` itself, because `Loop` + is generic over a callable returning `Any`, which cannot be named under this + project's type-checking settings. + """ + + def is_running(self) -> bool: + """Whether this task is currently running.""" + + def cancel(self) -> None: + """Stop this task, without waiting for its current iteration to finish.""" + + def start(self, *args: object, **kwargs: object) -> object: + """Begin running this task.""" + + def restart(self, *args: object, **kwargs: object) -> None: + """Stop this task, then begin running it again.""" + + def change_interval(self, *, seconds: float) -> None: + """Change how long this task waits between iterations.""" + + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +def reapply_task_settings( + task: RestartableTask, + *, + changed_settings: "AbstractSet[str]", + enabled: bool, + enabled_setting_name: str | None, + interval: "datetime.timedelta", + interval_setting_name: str, +) -> None: + """ + Apply any changed enabled-flag or interval setting to the given background task. + + Pass `enabled_setting_name=None` where whether the task runs cannot be changed + without a restart; only its interval is then re-applied. + + A task's interval is captured when its cog class is defined, so (unlike most + settings) it does not follow the loaded configuration by itself and must be + re-applied here. + + Changing the interval of an already-running task would otherwise only take effect + once its current wait had elapsed, which for a multi-hour interval could be long + after the change was made, so the task is restarted to apply it immediately. + """ + ENABLED_CHANGED: Final[bool] = ( + enabled_setting_name is not None and enabled_setting_name in changed_settings + ) + INTERVAL_CHANGED: Final[bool] = interval_setting_name in changed_settings + + if not ENABLED_CHANGED and not INTERVAL_CHANGED: + return + + if ENABLED_CHANGED and not enabled: + if task.is_running(): + task.cancel() + logger.debug("Stopped the task controlled by %r.", enabled_setting_name) + return + + if INTERVAL_CHANGED: + task.change_interval(seconds=interval.total_seconds()) + logger.debug("Changed %r to %s.", interval_setting_name, interval) + + if not task.is_running(): + _ = task.start() + logger.debug("Started the task controlled by %r.", enabled_setting_name) + return + + if INTERVAL_CHANGED: + task.restart() diff --git a/utils/tex_bot_base_cog.py b/utils/tex_bot_base_cog.py index e18c06e9a..bb7eaf85a 100644 --- a/utils/tex_bot_base_cog.py +++ b/utils/tex_bot_base_cog.py @@ -68,6 +68,19 @@ def __init__(self, bot: "TeXBot") -> None: """ self.bot: TeXBot = bot # NOTE: See https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/261 + async def on_config_reloaded(self, changed_settings: "AbstractSet[str]") -> None: + """ + Re-apply any changed configuration settings that this cog holds a copy of. + + Called upon every cog after the configuration has been reloaded, with the set of + settings key paths whose values changed. + + Most settings need no action here, because they are read from the settings + accessor at the point they are used, so a reload takes effect immediately. + Only settings captured elsewhere (the interval of a task, for example) need + re-applying, so this does nothing unless a cog overrides it. + """ + async def command_send_error( self, ctx: "TeXBotApplicationContext", From 2b582a44615411a23b2b88001499e3c8bf8805f9 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:43:23 +0100 Subject: [PATCH 20/33] Require a restart for every recurring task setting Reverts applying background task settings while TeX-Bot is running. Whether a task runs is decided when its cog is initialised, and how often it runs is fixed when its cog class is defined, so keeping either in step with the configuration meant starting, stopping & restarting tasks underneath themselves. Reporting that a restart is needed is easier to reason about, and to predict, than a task being torn down and recreated part-way through its work. `/config reload` therefore now reports the `enabled` & `interval` of all three recurring tasks as needing a restart, alongside the bot token and main guild ID. Their `delay` settings are unaffected: those are read from the settings accessor inside the task body, so they continue to take effect immediately, and flagging them would send committee members off to restart TeX-Bot for no reason. The reason for each of these is documented in the example configuration file, where it will be read while the file is being filled in. Removes the machinery that existed only to re-apply settings to running tasks: the per-task helper, the `on_config_reloaded` hook offered to every cog, and the loop that dispatched to it. With nothing left to dispatch to, working out which changed settings need a restart now lives beside the reload itself, and the command calls straight into it. --- cogs/check_su_platform_authorisation.py | 14 +--- cogs/config.py | 45 ++---------- cogs/send_get_roles_reminders.py | 15 +--- cogs/send_introduction_reminders.py | 22 +----- config/__init__.py | 31 +++++++-- config/_schema.py | 10 +-- tex-bot-deployment.example.yaml | 14 ++++ utils/__init__.py | 3 - utils/config_reload.py | 92 ------------------------- utils/tex_bot_base_cog.py | 13 ---- 10 files changed, 53 insertions(+), 206 deletions(-) delete mode 100644 utils/config_reload.py diff --git a/cogs/check_su_platform_authorisation.py b/cogs/check_su_platform_authorisation.py index 32cc28559..3b8124333 100644 --- a/cogs/check_su_platform_authorisation.py +++ b/cogs/check_su_platform_authorisation.py @@ -9,7 +9,7 @@ from discord.ext import tasks from config import settings -from utils import CommandChecks, TeXBotBaseCog, reapply_task_settings +from utils import CommandChecks, TeXBotBaseCog from utils.error_capture_decorators import ( capture_guild_does_not_exist_error, ) @@ -225,18 +225,6 @@ def cog_unload(self) -> None: """ self.su_platform_access_cookie_check_task.cancel() - @override - async def on_config_reloaded(self, changed_settings: "AbstractSet[str]") -> None: - """Apply any change to whether this task runs, or how often it runs.""" - reapply_task_settings( - self.su_platform_access_cookie_check_task, - changed_settings=changed_settings, - enabled=settings.community_group.msl.auto_cookie_checking.enabled, - enabled_setting_name="community-group:msl:auto-cookie-checking:enabled", - interval=settings.community_group.msl.auto_cookie_checking.interval, - interval_setting_name="community-group:msl:auto-cookie-checking:interval", - ) - @tasks.loop( seconds=settings.community_group.msl.auto_cookie_checking.interval.total_seconds() ) diff --git a/cogs/config.py b/cogs/config.py index b67136806..a1e9055e3 100644 --- a/cogs/config.py +++ b/cogs/config.py @@ -6,20 +6,19 @@ import discord import config -from config import SettingsValidationError, get_settings_metadata +from config import SettingsValidationError from utils import CommandChecks, TeXBotBaseCog if TYPE_CHECKING: - from collections.abc import Iterable, Mapping, Sequence + from collections.abc import Iterable, Sequence from collections.abc import Set as AbstractSet from logging import Logger from typing import Final - from config import ConfigSettingMetadata - from utils import TeXBot, TeXBotApplicationContext + from utils import TeXBotApplicationContext -__all__: "Sequence[str]" = ("ConfigCommandsCog", "reload_config") +__all__: "Sequence[str]" = ("ConfigCommandsCog",) logger: "Final[Logger]" = logging.getLogger("TeX-Bot") @@ -27,40 +26,6 @@ MAXIMUM_LISTED_SETTINGS: "Final[int]" = 20 -async def reload_config(bot: "TeXBot") -> "tuple[AbstractSet[str], AbstractSet[str]]": - """ - Reload the configuration file, applying every change that can be applied while running. - - Returns the set of settings key paths that changed, along with the subset of those - that cannot take effect until TeX-Bot is restarted. - - Raises `SettingsValidationError` (or one of the file-reading errors) without applying - anything, if the configuration file cannot be read or contains invalid settings. - """ - CHANGED_SETTINGS: Final[AbstractSet[str]] = config.reload_settings() - - if not CHANGED_SETTINGS: - return CHANGED_SETTINGS, frozenset() - - # NOTE: Every cog is offered the change, so that a cog holding a copy of any setting - # (the interval of a task, for example) can re-apply it to itself. - cog: discord.Cog - for cog in bot.cogs.values(): - if isinstance(cog, TeXBotBaseCog): - await cog.on_config_reloaded(CHANGED_SETTINGS) - - SETTINGS_METADATA: Final[Mapping[str, ConfigSettingMetadata]] = get_settings_metadata() - - RESTART_REQUIRED_SETTINGS: Final[AbstractSet[str]] = frozenset( - changed_setting - for changed_setting in CHANGED_SETTINGS - if changed_setting in SETTINGS_METADATA - and SETTINGS_METADATA[changed_setting].requires_restart - ) - - return CHANGED_SETTINGS, RESTART_REQUIRED_SETTINGS - - def _format_settings_list(settings_names: "Iterable[str]") -> str: """Format the given settings key paths into a bulleted list, truncated if very long.""" SORTED_SETTINGS_NAMES: Final[Sequence[str]] = sorted(settings_names) @@ -106,7 +71,7 @@ async def reload(self, ctx: "TeXBotApplicationContext") -> None: configuration_error: Exception try: - changed_settings, restart_required_settings = await reload_config(self.bot) + changed_settings, restart_required_settings = config.reload_settings() except SettingsValidationError as configuration_error: logger.warning("Configuration reload rejected:\n%s", configuration_error) await ctx.respond( diff --git a/cogs/send_get_roles_reminders.py b/cogs/send_get_roles_reminders.py index 6b8b3d0f5..0f29f1bc5 100644 --- a/cogs/send_get_roles_reminders.py +++ b/cogs/send_get_roles_reminders.py @@ -12,7 +12,7 @@ from config import settings from db.core.models import DiscordMember, SentGetRolesReminderMember from exceptions import GuestRoleDoesNotExistError -from utils import TeXBotBaseCog, reapply_task_settings +from utils import TeXBotBaseCog from utils.error_capture_decorators import ( ErrorCaptureDecorators, capture_guild_does_not_exist_error, @@ -21,7 +21,6 @@ if TYPE_CHECKING: import datetime from collections.abc import Sequence - from collections.abc import Set as AbstractSet from logging import Logger from typing import Final @@ -53,18 +52,6 @@ def cog_unload(self) -> None: """ self.send_get_roles_reminders.cancel() - @override - async def on_config_reloaded(self, changed_settings: "AbstractSet[str]") -> None: - """Apply any change to whether this task runs, or how often it runs.""" - reapply_task_settings( - self.send_get_roles_reminders, - changed_settings=changed_settings, - enabled=settings.reminders.send_get_roles_reminders.enabled, - enabled_setting_name="reminders:send-get-roles-reminders:enabled", - interval=settings.reminders.send_get_roles_reminders.interval, - interval_setting_name="reminders:send-get-roles-reminders:interval", - ) - @tasks.loop(seconds=settings.reminders.send_get_roles_reminders.interval.total_seconds()) @functools.partial( ErrorCaptureDecorators.capture_error_and_close, diff --git a/cogs/send_introduction_reminders.py b/cogs/send_introduction_reminders.py index df82cda6c..5384d33c4 100644 --- a/cogs/send_introduction_reminders.py +++ b/cogs/send_introduction_reminders.py @@ -19,7 +19,7 @@ SentOneOffIntroductionReminderMember, ) from exceptions import DiscordMemberNotInMainGuildError, GuestRoleDoesNotExistError -from utils import TeXBotBaseCog, reapply_task_settings +from utils import TeXBotBaseCog from utils.error_capture_decorators import ( ErrorCaptureDecorators, capture_guild_does_not_exist_error, @@ -27,7 +27,6 @@ if TYPE_CHECKING: from collections.abc import Sequence - from collections.abc import Set as AbstractSet from logging import Logger from typing import Final @@ -62,25 +61,6 @@ def cog_unload(self) -> None: """ self.send_introduction_reminders.cancel() - @override - async def on_config_reloaded(self, changed_settings: "AbstractSet[str]") -> None: - """ - Apply any change to how often this task runs. - - NOTE: A change to whether this task runs at all is deliberately not applied here. - Enabling it with a value of `interval` also clears the record of which members - have already been sent a one-off reminder, and that should not happen implicitly - during a reload, so that setting requires a restart instead. - """ - reapply_task_settings( - self.send_introduction_reminders, - changed_settings=changed_settings, - enabled=bool(settings.reminders.send_introduction_reminders.enabled), - enabled_setting_name=None, - interval=settings.reminders.send_introduction_reminders.interval, - interval_setting_name="reminders:send-introduction-reminders:interval", - ) - @TeXBotBaseCog.listener() async def on_ready(self) -> None: """Add OptOutIntroductionRemindersView to the bot's list of permanent views.""" diff --git a/config/__init__.py b/config/__init__.py index 95eb434d1..b8383ff70 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -9,7 +9,7 @@ import importlib import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple from ._accessor import SettingsAccessor, SettingsNotLoadedError, SettingsValidationError from ._document import ( @@ -23,13 +23,14 @@ from ._schema import ConfigSettingMetadata, get_settings_metadata if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence from collections.abc import Set as AbstractSet from logging import Logger from typing import Final __all__: "Sequence[str]" = ( + "ConfigReloadResult", "ConfigSettingMetadata", "InvalidSettingsFileError", "SettingsDocument", @@ -52,11 +53,19 @@ messages: "Final[MessagesAccessor]" = MessagesAccessor() -def reload_settings() -> "AbstractSet[str]": +class ConfigReloadResult(NamedTuple): + """The outcome of reloading the deployment configuration file.""" + + changed_settings: "AbstractSet[str]" + restart_required_settings: "AbstractSet[str]" + + +def reload_settings() -> ConfigReloadResult: """ Reload the deployment configuration file, applying any settings that have changed. - Returns the set of settings key paths whose values have changed. + Returns the settings key paths whose values have changed, along with the subset of + those that cannot take effect until TeX-Bot is restarted. The currently loaded configuration is left untouched if the file cannot be read or contains invalid settings. @@ -66,7 +75,19 @@ def reload_settings() -> "AbstractSet[str]": if any(changed_setting.startswith("logging:") for changed_setting in CHANGED_SETTINGS): apply_logging_settings(settings.logging) - return CHANGED_SETTINGS + SETTINGS_METADATA: Final[Mapping[str, ConfigSettingMetadata]] = get_settings_metadata() + + return ConfigReloadResult( + changed_settings=CHANGED_SETTINGS, + # NOTE: Read from the schema itself, rather than tracked separately, so that the + # two cannot disagree about which settings need a restart. + restart_required_settings=frozenset( + changed_setting + for changed_setting in CHANGED_SETTINGS + if changed_setting in SETTINGS_METADATA + and SETTINGS_METADATA[changed_setting].requires_restart + ), + ) def run_setup() -> None: diff --git a/config/_schema.py b/config/_schema.py index 4dcb814dd..4d761afdc 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -368,12 +368,12 @@ class AutoCookieCheckingSettings(_BaseSettingsSchema): # type: ignore[explicit- "Whether the MSL authentication cookie should be automatically checked " "to determine whether it is still valid." ), - json_schema_extra={"requires_restart": False, "secret": False}, + json_schema_extra={"requires_restart": True, "secret": False}, ) interval: TimeDelta = Field( default=datetime.timedelta(minutes=10), description="The interval of time between checking the MSL authentication cookie.", - json_schema_extra={"requires_restart": False, "secret": False}, + json_schema_extra={"requires_restart": True, "secret": False}, ) @@ -594,7 +594,7 @@ class SendIntroductionRemindersSettings(_BaseSettingsSchema): # type: ignore[ex "that are not inducted.\n" "Is ignored unless `enabled` **=** `interval`." ), - json_schema_extra={"requires_restart": False, "secret": False}, + json_schema_extra={"requires_restart": True, "secret": False}, ) @@ -608,7 +608,7 @@ class ReminderSettings(_BaseSettingsSchema): # type: ignore[explicit-any] "saying that they can get opt-in roles. " "(This message will only be sent once per Discord member.)" ), - json_schema_extra={"requires_restart": False, "secret": False}, + json_schema_extra={"requires_restart": True, "secret": False}, ) delay: TimeDelta = Field( default=datetime.timedelta(hours=40), @@ -626,7 +626,7 @@ class ReminderSettings(_BaseSettingsSchema): # type: ignore[explicit-any] "that should be sent a get-roles reminder.\n" "Is ignored if `enabled` **=** `false`." ), - json_schema_extra={"requires_restart": False, "secret": False}, + json_schema_extra={"requires_restart": True, "secret": False}, ) diff --git a/tex-bot-deployment.example.yaml b/tex-bot-deployment.example.yaml index d7a36f44b..ccc113ce6 100644 --- a/tex-bot-deployment.example.yaml +++ b/tex-bot-deployment.example.yaml @@ -9,6 +9,20 @@ # # Durations are written largest-unit-first, in the format # `dhms`, so `1h30m` and `2d` are both valid. +# +# Run `/config reload` after editing this file to apply your changes. +# Most settings take effect straight away, because they are read at the moment they +# are used. A few cannot, and `/config reload` will tell you when a restart is needed: +# +# * `discord:bot-token` & `discord:main-guild-id` are used to connect to Discord and +# to look up your guild's roles & channels while TeX-Bot is starting up. +# * The `enabled` & `interval` of any recurring task (the reminders below, and MSL +# auto-cookie-checking) are fixed when that task is created at start-up. Their +# `delay` settings are *not*, and do take effect immediately. +# +# A reload never applies a configuration that fails validation: if this file contains a +# mistake, TeX-Bot keeps running on the last configuration that loaded successfully and +# reports the line responsible. discord: # REQUIRED. From your bot's page within the Discord developer portal: diff --git a/utils/__init__.py b/utils/__init__.py index daadd9df4..48ce5285b 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -8,7 +8,6 @@ import discord from .command_checks import CommandChecks -from .config_reload import RestartableTask, reapply_task_settings from .message_sender_components import MessageSavingSenderComponent from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot @@ -24,7 +23,6 @@ "AllChannelTypes", "CommandChecks", "MessageSavingSenderComponent", - "RestartableTask", "SuppressTraceback", "TeXBot", "TeXBotApplicationContext", @@ -33,7 +31,6 @@ "generate_invite_url", "is_member_inducted", "is_running_in_async", - "reapply_task_settings", ) diff --git a/utils/config_reload.py b/utils/config_reload.py deleted file mode 100644 index 4a0d8b533..000000000 --- a/utils/config_reload.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Helpers for re-applying changed configuration settings to running background tasks.""" - -import logging -from typing import TYPE_CHECKING, Protocol - -if TYPE_CHECKING: - import datetime - from collections.abc import Sequence - from collections.abc import Set as AbstractSet - from logging import Logger - from typing import Final - - -__all__: "Sequence[str]" = ("RestartableTask", "reapply_task_settings") - - -class RestartableTask(Protocol): - """ - The parts of a `discord.ext.tasks.Loop` needed to re-apply its settings. - - NOTE: Declared structurally, rather than referring to `Loop` itself, because `Loop` - is generic over a callable returning `Any`, which cannot be named under this - project's type-checking settings. - """ - - def is_running(self) -> bool: - """Whether this task is currently running.""" - - def cancel(self) -> None: - """Stop this task, without waiting for its current iteration to finish.""" - - def start(self, *args: object, **kwargs: object) -> object: - """Begin running this task.""" - - def restart(self, *args: object, **kwargs: object) -> None: - """Stop this task, then begin running it again.""" - - def change_interval(self, *, seconds: float) -> None: - """Change how long this task waits between iterations.""" - - -logger: "Final[Logger]" = logging.getLogger("TeX-Bot") - - -def reapply_task_settings( - task: RestartableTask, - *, - changed_settings: "AbstractSet[str]", - enabled: bool, - enabled_setting_name: str | None, - interval: "datetime.timedelta", - interval_setting_name: str, -) -> None: - """ - Apply any changed enabled-flag or interval setting to the given background task. - - Pass `enabled_setting_name=None` where whether the task runs cannot be changed - without a restart; only its interval is then re-applied. - - A task's interval is captured when its cog class is defined, so (unlike most - settings) it does not follow the loaded configuration by itself and must be - re-applied here. - - Changing the interval of an already-running task would otherwise only take effect - once its current wait had elapsed, which for a multi-hour interval could be long - after the change was made, so the task is restarted to apply it immediately. - """ - ENABLED_CHANGED: Final[bool] = ( - enabled_setting_name is not None and enabled_setting_name in changed_settings - ) - INTERVAL_CHANGED: Final[bool] = interval_setting_name in changed_settings - - if not ENABLED_CHANGED and not INTERVAL_CHANGED: - return - - if ENABLED_CHANGED and not enabled: - if task.is_running(): - task.cancel() - logger.debug("Stopped the task controlled by %r.", enabled_setting_name) - return - - if INTERVAL_CHANGED: - task.change_interval(seconds=interval.total_seconds()) - logger.debug("Changed %r to %s.", interval_setting_name, interval) - - if not task.is_running(): - _ = task.start() - logger.debug("Started the task controlled by %r.", enabled_setting_name) - return - - if INTERVAL_CHANGED: - task.restart() diff --git a/utils/tex_bot_base_cog.py b/utils/tex_bot_base_cog.py index bb7eaf85a..e18c06e9a 100644 --- a/utils/tex_bot_base_cog.py +++ b/utils/tex_bot_base_cog.py @@ -68,19 +68,6 @@ def __init__(self, bot: "TeXBot") -> None: """ self.bot: TeXBot = bot # NOTE: See https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/261 - async def on_config_reloaded(self, changed_settings: "AbstractSet[str]") -> None: - """ - Re-apply any changed configuration settings that this cog holds a copy of. - - Called upon every cog after the configuration has been reloaded, with the set of - settings key paths whose values changed. - - Most settings need no action here, because they are read from the settings - accessor at the point they are used, so a reload takes effect immediately. - Only settings captured elsewhere (the interval of a task, for example) need - re-applying, so this does nothing unless a cog overrides it. - """ - async def command_send_error( self, ctx: "TeXBotApplicationContext", From 896bd53e3bf32c4c1ea06fc18a294f097dd46857 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:56:32 +0100 Subject: [PATCH 21/33] Add a test suite for the configuration package Adds 156 tests covering the settings schema, the configuration file reader & writer, the settings accessor, the messages accessor, applying the logging configuration, and reloading. Coverage of the config package is 98%; the remainder is the database setup performed at start-up. Writing these found three defects: - Reloading raised an unhandled exception when the configuration file was missing or malformed. `/config reload` caught `OSError`, but neither `SettingsFileNotFoundError` nor `InvalidSettingsFileError` inherits from it, so the command failed rather than reporting the problem. Both are now caught explicitly. - A duration given as a bare number was accepted as a count of seconds, so `timeout-duration: 24` silently meant 24 seconds rather than the 24 hours its author would have intended. This was inconsistent with the string `'24'`, which was already rejected for having no unit; a unit is now always required. - Pytest was reading none of its configuration, because it only looks in `[tool.pytest.ini_options]` and the settings were in a plain `[tool.pytest]` table. The settings accessor is a module-level singleton, and reloading reports what changed relative to what was loaded before, so the tests that reload through the package's public surface replace it with an empty accessor beforehand. Without that the results would depend upon the order the tests happened to run in; the suite passes in shuffled order. --- cogs/config.py | 12 +- config/_schema.py | 16 +- pyproject.toml | 7 +- tests/config/__init__.py | 8 + tests/config/conftest.py | 63 +++++ tests/config/test_accessor.py | 317 +++++++++++++++++++++++ tests/config/test_document.py | 387 ++++++++++++++++++++++++++++ tests/config/test_logging.py | 205 +++++++++++++++ tests/config/test_messages.py | 185 ++++++++++++++ tests/config/test_reload.py | 248 ++++++++++++++++++ tests/config/test_schema.py | 469 ++++++++++++++++++++++++++++++++++ 11 files changed, 1912 insertions(+), 5 deletions(-) create mode 100644 tests/config/__init__.py create mode 100644 tests/config/conftest.py create mode 100644 tests/config/test_accessor.py create mode 100644 tests/config/test_document.py create mode 100644 tests/config/test_logging.py create mode 100644 tests/config/test_messages.py create mode 100644 tests/config/test_reload.py create mode 100644 tests/config/test_schema.py diff --git a/cogs/config.py b/cogs/config.py index a1e9055e3..e87ea7a31 100644 --- a/cogs/config.py +++ b/cogs/config.py @@ -6,7 +6,11 @@ import discord import config -from config import SettingsValidationError +from config import ( + InvalidSettingsFileError, + SettingsFileNotFoundError, + SettingsValidationError, +) from utils import CommandChecks, TeXBotBaseCog if TYPE_CHECKING: @@ -84,7 +88,11 @@ async def reload(self, ctx: "TeXBotApplicationContext") -> None: ephemeral=True, ) return - except (OSError, ValueError) as configuration_error: + except ( + SettingsFileNotFoundError, + InvalidSettingsFileError, + OSError, + ) as configuration_error: logger.warning("Configuration reload failed: %s", configuration_error) await ctx.respond( ( diff --git a/config/_schema.py b/config/_schema.py index 4d761afdc..853958336 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -134,9 +134,23 @@ def _parse_time_delta(value: object) -> object: an empty string is rejected rather than silently parsed as a zero-length duration. A zero-length interval would cause any task looping upon it to spin without pausing. """ - if not isinstance(value, str): + if isinstance(value, datetime.timedelta): return value + if not isinstance(value, str): + # NOTE: Pydantic would otherwise accept a bare number as a count of seconds, so + # `timeout-duration: 24` would silently mean 24 seconds rather than the 24 hours + # its author almost certainly intended. Requiring a unit removes the ambiguity, + # and matches the string `'24'` already being rejected for the same reason. + NON_STRING_TIME_DELTA_MESSAGE: str = ( + "Value should be a delay/interval string, in the format " + "'dhms', including the unit of each part" + ) + # NOTE: Deliberately a `ValueError` despite describing a wrong type: Pydantic + # converts only `ValueError` & `AssertionError` into validation errors, so a + # `TypeError` would propagate uncaught & abandon the whole reload. + raise ValueError(NON_STRING_TIME_DELTA_MESSAGE) # noqa: TRY004 + match: re.Match[str] | None = _TIME_DELTA_MATCHER.fullmatch(value.strip()) if match is None or not any(match.groupdict().values()): # NOTE: Raising here (rather than deferring to Pydantic's own timedelta parsing) diff --git a/pyproject.toml b/pyproject.toml index 6601dbd1e..cd90a16ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,9 +108,12 @@ plugins.ul-indent.indent = 4 plugins.ul-start-left.enabled = true plugins.ul-style.style = "asterisk" -[tool.pytest] +# NOTE: Pytest only reads its configuration from the `ini_options` table within +# `pyproject.toml`; a plain `[tool.pytest]` table is silently ignored. +[tool.pytest.ini_options] filterwarnings = ["ignore:'audioop':DeprecationWarning"] -strict = true +testpaths = ["tests"] +xfail_strict = true [tool.ruff] extend-exclude = ["db/**/migrations/"] diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 000000000..b9bbfd5d9 --- /dev/null +++ b/tests/config/__init__.py @@ -0,0 +1,8 @@ +"""Test suite for the config package.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__: "Sequence[str]" = () diff --git a/tests/config/conftest.py b/tests/config/conftest.py new file mode 100644 index 000000000..603ba807f --- /dev/null +++ b/tests/config/conftest.py @@ -0,0 +1,63 @@ +"""Shared fixtures & constants for the config package test suite.""" + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from pathlib import Path + from typing import Final + + type ConfigWriter = "Callable[[str], Path]" + + +__all__: "Sequence[str]" = ( + "CHANGED_EASTER_EGG_PROBABILITY", + "DEFAULT_EASTER_EGG_PROBABILITY", + "MINIMAL_CONFIG", + "VALID_BOT_TOKEN", + "VALID_MAIN_GUILD_ID", + "VALID_WEBHOOK_URL", +) + + +# NOTE: A fabricated value, structured to satisfy the bot-token pattern: +# 24-26 characters, then 6, then 27-38. +VALID_BOT_TOKEN: "Final[str]" = "MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se7kKWs" # noqa: S105 + +VALID_MAIN_GUILD_ID: "Final[int]" = 1234567890123456789 + +VALID_WEBHOOK_URL: "Final[str]" = "https://discord.com/api/webhooks/123456789/abcdefg" + +DEFAULT_EASTER_EGG_PROBABILITY: "Final[float]" = 0.01 + +CHANGED_EASTER_EGG_PROBABILITY: "Final[float]" = 0.5 + +MINIMAL_CONFIG: "Final[str]" = f"""\ +# A deployment configuration holding only the settings that are required. +discord: + bot-token: {VALID_BOT_TOKEN} + main-guild-id: {VALID_MAIN_GUILD_ID} +community-group: + links: {{}} + msl: {{}} +""" + + +@pytest.fixture() +def write_config(tmp_path: "Path") -> "ConfigWriter": + """Return a callable writing the given YAML into a configuration file.""" + + def _write_config(raw_yaml: str) -> "Path": + config_file_path: Path = tmp_path / "tex-bot-deployment.yaml" + config_file_path.write_text(raw_yaml, encoding="utf-8") + return config_file_path + + return _write_config + + +@pytest.fixture() +def config_file(write_config: "ConfigWriter") -> "Path": + """Return the path of a configuration file holding only the required settings.""" + return write_config(MINIMAL_CONFIG) diff --git a/tests/config/test_accessor.py b/tests/config/test_accessor.py new file mode 100644 index 000000000..55ac56924 --- /dev/null +++ b/tests/config/test_accessor.py @@ -0,0 +1,317 @@ +"""Test suite for run-time access to the validated configuration.""" + +import datetime +from typing import TYPE_CHECKING + +import pytest + +from config import ( + SettingsFileNotFoundError, + SettingsNotLoadedError, + SettingsValidationError, +) +from config._accessor import SettingsAccessor + +from .conftest import ( + CHANGED_EASTER_EGG_PROBABILITY, + DEFAULT_EASTER_EGG_PROBABILITY, + MINIMAL_CONFIG, + VALID_BOT_TOKEN, + VALID_WEBHOOK_URL, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + from collections.abc import Set as AbstractSet + from pathlib import Path + from typing import Final + + type ConfigWriter = "Callable[[str], Path]" + +__all__: "Sequence[str]" = () + + +CONFIG_WITH_OPTIONAL_SETTINGS: "Final[str]" = f"""\ +discord: + bot-token: {VALID_BOT_TOKEN} + main-guild-id: 1234567890123456789 +community-group: + full-name: Computer Science Society + links: {{}} + msl: {{}} +commands: + ping: + easter-egg-probability: 0.01 + strike: + timeout-duration: 24h +""" + + +@pytest.fixture() +def loaded_accessor(config_file: "Path") -> SettingsAccessor: + """Return an accessor that has loaded the minimal configuration.""" + settings: SettingsAccessor = SettingsAccessor() + settings.reload(config_file) + return settings + + +class TestLoading: + """Test case for loading configuration into the accessor.""" + + @staticmethod + def test_accessing_settings_before_loading_is_refused() -> None: + """Test that reading a setting before any configuration is loaded is refused.""" + settings: SettingsAccessor = SettingsAccessor() + + assert not settings.is_loaded + + with pytest.raises(SettingsNotLoadedError): + _ = settings.discord.main_guild_id + + @staticmethod + def test_loading_makes_settings_available(config_file: "Path") -> None: + """Test that settings can be read once a configuration has been loaded.""" + settings: SettingsAccessor = SettingsAccessor() + + settings.reload(config_file) + + assert settings.is_loaded + assert settings.discord.main_guild_id == 1234567890123456789 + assert settings.file_path == config_file + + @staticmethod + def test_defaults_are_available_for_omitted_settings( + loaded_accessor: SettingsAccessor, + ) -> None: + """Test that a setting left out of the file still has its default value.""" + assert loaded_accessor.commands.strike.timeout_duration == datetime.timedelta(hours=24) + assert loaded_accessor.community_group.membership_dependent_roles == () + + @staticmethod + def test_settings_are_typed(loaded_accessor: SettingsAccessor) -> None: + """Test that settings are exposed as the types they represent.""" + assert isinstance(loaded_accessor.discord.main_guild_id, int) + assert isinstance(loaded_accessor.commands.strike.timeout_duration, datetime.timedelta) + assert isinstance(loaded_accessor.commands.stats.lookback_days, float) + + +class TestChangeDetection: + """Test case for reporting which settings changed upon each reload.""" + + @staticmethod + def test_first_load_reports_every_setting_as_changed(config_file: "Path") -> None: + """Test that loading a configuration for the first time reports all of it.""" + settings: SettingsAccessor = SettingsAccessor() + + assert len(settings.reload(config_file)) > 1 + + @staticmethod + def test_reloading_an_unchanged_file_reports_nothing( + loaded_accessor: SettingsAccessor, config_file: "Path" + ) -> None: + """Test that reloading an untouched configuration reports no changes.""" + assert loaded_accessor.reload(config_file) == set() + + @staticmethod + def test_a_single_change_is_reported_alone( + write_config: "ConfigWriter", + ) -> None: + """Test that changing one setting reports only that setting.""" + settings: SettingsAccessor = SettingsAccessor() + settings.reload(write_config(CONFIG_WITH_OPTIONAL_SETTINGS)) + + CHANGED_SETTINGS: Final[AbstractSet[str]] = settings.reload( + write_config( + CONFIG_WITH_OPTIONAL_SETTINGS.replace( + "easter-egg-probability: 0.01", "easter-egg-probability: 0.5" + ) + ) + ) + + assert {"commands:ping:easter-egg-probability"} == CHANGED_SETTINGS + assert settings.commands.ping.easter_egg_probability == CHANGED_EASTER_EGG_PROBABILITY + + @staticmethod + def test_an_appearing_section_reports_the_settings_within_it( + write_config: "ConfigWriter", + ) -> None: + """Test that adding an optional section reports the settings it introduces.""" + settings: SettingsAccessor = SettingsAccessor() + settings.reload(write_config(MINIMAL_CONFIG)) + + CHANGED_SETTINGS: Final[AbstractSet[str]] = settings.reload( + write_config( + f"{MINIMAL_CONFIG}" + f"logging:\n" + f" discord-channel:\n" + f" webhook-url: {VALID_WEBHOOK_URL}\n" + ) + ) + + assert "logging:discord-channel:webhook-url" in CHANGED_SETTINGS + assert settings.logging.discord_channel is not None + + +class TestFailedReloads: + """Test case for what happens when a configuration cannot be loaded.""" + + @staticmethod + def test_invalid_configuration_is_rejected( + loaded_accessor: SettingsAccessor, write_config: "ConfigWriter" + ) -> None: + """Test that a configuration failing validation is refused.""" + with pytest.raises(SettingsValidationError): + loaded_accessor.reload( + write_config( + f"{MINIMAL_CONFIG}commands:\n ping:\n easter-egg-probability: 9.9\n" + ) + ) + + @staticmethod + def test_invalid_configuration_leaves_the_previous_one_running( + loaded_accessor: SettingsAccessor, write_config: "ConfigWriter" + ) -> None: + """ + Test that a rejected configuration does not disturb the one already loaded. + + A mistake within the configuration file must not be able to take down a bot + that is running perfectly well. + """ + PREVIOUS_MAIN_GUILD_ID: Final[int] = loaded_accessor.discord.main_guild_id + + with pytest.raises(SettingsValidationError): + loaded_accessor.reload( + write_config( + "discord:\n" + " bot-token: not-a-real-token\n" + " main-guild-id: 9876543210987654321\n" + "community-group:\n" + " links: {}\n" + " msl: {}\n" + ) + ) + + assert loaded_accessor.is_loaded + assert loaded_accessor.discord.main_guild_id == PREVIOUS_MAIN_GUILD_ID + + @staticmethod + def test_a_missing_file_leaves_the_previous_configuration_running( + loaded_accessor: SettingsAccessor, tmp_path: "Path" + ) -> None: + """Test that failing to find the file does not disturb the loaded configuration.""" + PREVIOUS_MAIN_GUILD_ID: Final[int] = loaded_accessor.discord.main_guild_id + + with pytest.raises(SettingsFileNotFoundError, match="No configuration file"): + loaded_accessor.reload(tmp_path / "vanished.yaml") + + assert loaded_accessor.discord.main_guild_id == PREVIOUS_MAIN_GUILD_ID + + @staticmethod + def test_validation_errors_name_the_line_responsible( + loaded_accessor: SettingsAccessor, write_config: "ConfigWriter" + ) -> None: + """Test that a rejected configuration explains where the mistake is.""" + with pytest.raises(SettingsValidationError, match=r"tex-bot-deployment\.yaml:"): + loaded_accessor.reload( + write_config( + f"{MINIMAL_CONFIG}logging:\n console:\n log-level: NONSENSE\n" + ) + ) + + +class TestSnapshotIsolation: + """Test case for the immutability of a loaded configuration.""" + + @staticmethod + def test_settings_cannot_be_modified(loaded_accessor: SettingsAccessor) -> None: + """Test that a loaded setting cannot be overwritten in place.""" + with pytest.raises(ValueError, match="frozen"): + loaded_accessor.discord.main_guild_id = 1 # type: ignore[misc] + + @staticmethod + def test_a_previously_read_section_is_unaffected_by_a_reload( + write_config: "ConfigWriter", + ) -> None: + """ + Test that a section read before a reload keeps the values it was read with. + + Replacing the whole snapshot, rather than mutating settings individually, is + what stops a caller part-way through its work from seeing a mixture of the old + and new configurations. + """ + settings: SettingsAccessor = SettingsAccessor() + settings.reload(write_config(CONFIG_WITH_OPTIONAL_SETTINGS)) + + commands_before_reload = settings.commands + + settings.reload( + write_config( + CONFIG_WITH_OPTIONAL_SETTINGS.replace( + "easter-egg-probability: 0.01", "easter-egg-probability: 0.5" + ) + ) + ) + + assert ( + commands_before_reload.ping.easter_egg_probability + == DEFAULT_EASTER_EGG_PROBABILITY + ) + assert settings.commands.ping.easter_egg_probability == CHANGED_EASTER_EGG_PROBABILITY + + +class TestFlatMapping: + """Test case for listing every setting by name.""" + + @staticmethod + def test_settings_are_listed_by_key_path(loaded_accessor: SettingsAccessor) -> None: + """Test that every setting is available under its colon-separated key path.""" + FLAT_SETTINGS: Final[Mapping[str, object]] = loaded_accessor.as_flat_mapping() + + assert FLAT_SETTINGS["discord:main-guild-id"] == 1234567890123456789 + assert ( + FLAT_SETTINGS["commands:ping:easter-egg-probability"] + == DEFAULT_EASTER_EGG_PROBABILITY + ) + + @staticmethod + def test_secrets_are_not_revealed_by_listing_settings( + loaded_accessor: SettingsAccessor, + ) -> None: + """Test that listing every setting does not expose a secret value.""" + assert VALID_BOT_TOKEN not in repr(loaded_accessor.as_flat_mapping()) + + @staticmethod + def test_listing_settings_before_loading_is_refused() -> None: + """Test that listing settings before any are loaded is refused.""" + with pytest.raises(SettingsNotLoadedError): + SettingsAccessor().as_flat_mapping() + + +class TestSectionAccessors: + """Test case for reaching each section of the configuration.""" + + @staticmethod + def test_every_section_is_reachable(loaded_accessor: SettingsAccessor) -> None: + """Test that each top-level section of the configuration can be read.""" + assert loaded_accessor.logging.console.log_level == "INFO" + assert loaded_accessor.discord.main_guild_id == 1234567890123456789 + assert loaded_accessor.community_group.links.purchase_membership is None + assert ( + loaded_accessor.commands.ping.easter_egg_probability + == DEFAULT_EASTER_EGG_PROBABILITY + ) + assert loaded_accessor.reminders.send_get_roles_reminders.enabled is True + assert loaded_accessor.auto_add_committee_to_threads is True + + @staticmethod + def test_the_underlying_document_is_reachable( + loaded_accessor: SettingsAccessor, config_file: "Path" + ) -> None: + """ + Test that the document the configuration was parsed from is available. + + Rewriting an individual setting needs the document, rather than the validated + values taken from it, so that comments & formatting are retained. + """ + assert loaded_accessor.document.file_path == config_file + assert "discord" in loaded_accessor.document.raw diff --git a/tests/config/test_document.py b/tests/config/test_document.py new file mode 100644 index 000000000..57189f926 --- /dev/null +++ b/tests/config/test_document.py @@ -0,0 +1,387 @@ +"""Test suite for reading, writing & error-reporting of the configuration file.""" + +import os +from typing import TYPE_CHECKING +from unittest import mock + +import pytest +from pydantic import ValidationError + +from config import ( + InvalidSettingsFileError, + SettingsDocument, + SettingsFileNotFoundError, + get_settings_file_path, +) +from config._document import ( + SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, +) +from config._schema import SettingsSchema + +from .conftest import MINIMAL_CONFIG, VALID_BOT_TOKEN + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from pathlib import Path + from typing import Final + + type ConfigWriter = "Callable[[str], Path]" + +__all__: "Sequence[str]" = () + + +COMMENTED_CONFIG: "Final[str]" = f"""\ +# A leading comment about the whole file. + +discord: + # A comment about the bot token. + bot-token: {VALID_BOT_TOKEN} + main-guild-id: 1234567890123456789 # A trailing comment. + +community-group: + full-name: Computer Science Society # Another trailing comment. + links: {{}} + msl: {{}} +""" + + +class TestLoading: + """Test case for parsing the configuration file.""" + + @staticmethod + def test_valid_file_is_loaded(config_file: "Path") -> None: + """Test that a valid configuration file is parsed.""" + document: SettingsDocument = SettingsDocument.load(config_file) + + assert document.file_path == config_file + assert document.raw["discord"]["main-guild-id"] == 1234567890123456789 + + @staticmethod + def test_missing_file_is_reported(tmp_path: "Path") -> None: + """Test that a configuration file that does not exist is reported clearly.""" + with pytest.raises(SettingsFileNotFoundError): + SettingsDocument.load(tmp_path / "does-not-exist.yaml") + + @staticmethod + def test_malformed_yaml_is_reported(write_config: "ConfigWriter") -> None: + """Test that a file that is not valid YAML is reported clearly.""" + with pytest.raises(InvalidSettingsFileError, match="not a valid YAML file"): + SettingsDocument.load(write_config("discord:\n bot-token: [unclosed\n")) + + @staticmethod + def test_empty_file_is_reported(write_config: "ConfigWriter") -> None: + """Test that an empty configuration file is reported clearly.""" + with pytest.raises(InvalidSettingsFileError, match="is empty"): + SettingsDocument.load(write_config("")) + + @staticmethod + @pytest.mark.parametrize("raw_yaml", ("just-a-string\n", "- one\n- two\n", "42\n")) + def test_file_without_a_top_level_mapping_is_reported( + write_config: "ConfigWriter", raw_yaml: str + ) -> None: + """Test that a file not holding a mapping of settings is reported clearly.""" + with pytest.raises(InvalidSettingsFileError, match="must contain a mapping"): + SettingsDocument.load(write_config(raw_yaml)) + + +class TestRoundTripping: + """Test case for preserving the contents of a hand-written configuration file.""" + + @staticmethod + def test_unmodified_file_is_written_back_unchanged( + write_config: "ConfigWriter", + ) -> None: + """Test that loading & writing a file back leaves it byte-for-byte identical.""" + document: SettingsDocument = SettingsDocument.load(write_config(COMMENTED_CONFIG)) + + assert document.dump() == COMMENTED_CONFIG + + @staticmethod + def test_comments_survive_a_changed_value(write_config: "ConfigWriter") -> None: + """ + Test that changing a setting preserves every comment around it. + + This is what allows the `/config` command to edit a file that a human wrote & + annotated, without discarding their annotations. + """ + config_file_path: Path = write_config(COMMENTED_CONFIG) + document: SettingsDocument = SettingsDocument.load(config_file_path) + + document.raw["community-group"]["full-name"] = "CompSoc" + document.write() + + NEW_FILE_CONTENTS: Final[str] = config_file_path.read_text(encoding="utf-8") + + assert "CompSoc" in NEW_FILE_CONTENTS + assert "# A leading comment about the whole file." in NEW_FILE_CONTENTS + assert "# A comment about the bot token." in NEW_FILE_CONTENTS + assert "# A trailing comment." in NEW_FILE_CONTENTS + assert "# Another trailing comment." in NEW_FILE_CONTENTS + + @staticmethod + def test_written_file_can_be_loaded_again(write_config: "ConfigWriter") -> None: + """Test that a rewritten configuration file is still valid.""" + config_file_path: Path = write_config(COMMENTED_CONFIG) + document: SettingsDocument = SettingsDocument.load(config_file_path) + + document.raw["community-group"]["full-name"] = "CompSoc" + document.write() + + assert ( + SettingsSchema.model_validate( + SettingsDocument.load(config_file_path).raw + ).community_group.full_name + == "CompSoc" + ) + + +class TestWriting: + """Test case for persisting the configuration file to disk.""" + + @staticmethod + def _rejecting_replace(*_args: object, **_kwargs: object) -> None: + """Stand in for a rename that the filesystem refuses.""" + raise OSError(16, "Device or resource busy") + + @staticmethod + def test_no_temporary_file_is_left_behind( + write_config: "ConfigWriter", tmp_path: "Path" + ) -> None: + """Test that writing does not leave its temporary file behind.""" + document: SettingsDocument = SettingsDocument.load(write_config(MINIMAL_CONFIG)) + + document.write() + + assert not [path for path in tmp_path.iterdir() if path.suffix == ".tmp"] + + @staticmethod + def test_write_falls_back_when_the_file_cannot_be_replaced( + write_config: "ConfigWriter", tmp_path: "Path" + ) -> None: + """ + Test that a rejected rename falls back to writing the file directly. + + Replacing an individually mounted file within a container is rejected, so the + fallback is what allows a setting to be saved in that deployment. + """ + config_file_path: Path = write_config(COMMENTED_CONFIG) + document: SettingsDocument = SettingsDocument.load(config_file_path) + document.raw["community-group"]["full-name"] = "FallbackSoc" + + with mock.patch("config._document.os.replace", TestWriting._rejecting_replace): + document.write() + + NEW_FILE_CONTENTS: Final[str] = config_file_path.read_text(encoding="utf-8") + + assert "FallbackSoc" in NEW_FILE_CONTENTS + assert "# A leading comment about the whole file." in NEW_FILE_CONTENTS + assert not [path for path in tmp_path.iterdir() if path.suffix == ".tmp"] + + @staticmethod + def test_both_write_paths_produce_identical_output( + write_config: "ConfigWriter", tmp_path: "Path" + ) -> None: + """Test that falling back to writing directly produces the same file.""" + atomically_written_path: Path = write_config(COMMENTED_CONFIG) + atomically_written_document: SettingsDocument = SettingsDocument.load( + atomically_written_path + ) + atomically_written_document.raw["community-group"]["full-name"] = "Same" + atomically_written_document.write() + + directly_written_path: Path = tmp_path / "directly-written.yaml" + directly_written_path.write_text(COMMENTED_CONFIG, encoding="utf-8") + directly_written_document: SettingsDocument = SettingsDocument.load( + directly_written_path + ) + directly_written_document.raw["community-group"]["full-name"] = "Same" + with mock.patch("config._document.os.replace", TestWriting._rejecting_replace): + directly_written_document.write() + + assert atomically_written_path.read_text( + encoding="utf-8" + ) == directly_written_path.read_text(encoding="utf-8") + + @staticmethod + def test_failing_to_write_leaves_the_original_file_intact( + write_config: "ConfigWriter", tmp_path: "Path" + ) -> None: + """Test that a write that fails partway does not damage the existing file.""" + config_file_path: Path = write_config(COMMENTED_CONFIG) + document: SettingsDocument = SettingsDocument.load(config_file_path) + document.raw["community-group"]["full-name"] = "ShouldNotAppear" + + with ( + mock.patch( + "pathlib.Path.write_text", side_effect=OSError(28, "No space left on device") + ), + pytest.raises(OSError, match="No space left on device"), + ): + document.write() + + assert config_file_path.read_text(encoding="utf-8") == COMMENTED_CONFIG + assert not [path for path in tmp_path.iterdir() if path.suffix == ".tmp"] + + +class TestErrorReporting: + """Test case for pointing a human at the cause of an invalid configuration.""" + + @staticmethod + def test_errors_name_the_line_that_caused_them(write_config: "ConfigWriter") -> None: + """Test that each validation failure is reported against its source line.""" + document: SettingsDocument = SettingsDocument.load( + write_config( + "discord:\n" + " bot-token: not-a-real-token\n" + " main-guild-id: 12\n" + "community-group:\n" + " links: {}\n" + " msl: {}\n" + ) + ) + + with pytest.raises(ValidationError) as validation_error: + SettingsSchema.model_validate(document.raw) + + FORMATTED_ERROR: Final[str] = document.format_validation_error(validation_error.value) + + assert "tex-bot-deployment.yaml:2" in FORMATTED_ERROR + assert "tex-bot-deployment.yaml:3" in FORMATTED_ERROR + assert "discord:bot-token" in FORMATTED_ERROR + + @staticmethod + def test_errors_never_reveal_the_offending_value( + write_config: "ConfigWriter", + ) -> None: + """Test that a rejected value is not quoted back, in case it is a secret.""" + SECRET_LOOKING_TOKEN: Final[str] = "SUPER-SECRET-BUT-INVALID" # noqa: S105 + + document: SettingsDocument = SettingsDocument.load( + write_config( + f"discord:\n" + f" bot-token: {SECRET_LOOKING_TOKEN}\n" + f" main-guild-id: 1234567890123456789\n" + f"community-group:\n" + f" links: {{}}\n" + f" msl: {{}}\n" + ) + ) + + with pytest.raises(ValidationError) as validation_error: + SettingsSchema.model_validate(document.raw) + + assert SECRET_LOOKING_TOKEN not in document.format_validation_error( + validation_error.value + ) + + @staticmethod + def test_a_missing_setting_is_reported_against_its_nearest_section( + write_config: "ConfigWriter", + ) -> None: + """ + Test that an absent setting is still reported against a useful location. + + A setting that is missing entirely has no line of its own, so the closest + section that does exist is reported instead. + """ + document: SettingsDocument = SettingsDocument.load( + write_config( + "discord:\n" + " main-guild-id: 1234567890123456789\n" + "community-group:\n" + " links: {}\n" + " msl: {}\n" + ) + ) + + with pytest.raises(ValidationError) as validation_error: + SettingsSchema.model_validate(document.raw) + + FORMATTED_ERROR: Final[str] = document.format_validation_error(validation_error.value) + + assert "discord:bot-token" in FORMATTED_ERROR + assert "tex-bot-deployment.yaml:1" in FORMATTED_ERROR + + @staticmethod + def test_line_numbers_of_absent_settings_are_not_invented( + config_file: "Path", + ) -> None: + """Test that no line is reported for a key path that cannot be resolved at all.""" + document: SettingsDocument = SettingsDocument.load(config_file) + + assert document.line_number_of(["not-a-section", "not-a-setting"]) is None + + +class TestFileDiscovery: + """Test case for locating the configuration file.""" + + @staticmethod + def test_environment_variable_is_used_when_set( + config_file: "Path", monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that the configured environment variable names the file to load.""" + monkeypatch.setenv(SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, str(config_file)) + + assert get_settings_file_path() == config_file.resolve() + + @staticmethod + def test_missing_file_named_by_the_environment_variable_is_reported( + tmp_path: "Path", monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that naming a file that does not exist is reported clearly.""" + monkeypatch.setenv( + SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, str(tmp_path / "absent.yaml") + ) + + with pytest.raises(SettingsFileNotFoundError, match="environment variable"): + get_settings_file_path() + + @staticmethod + def test_absent_configuration_is_reported_with_guidance( + tmp_path: "Path", monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that finding no configuration at all explains how to provide one.""" + monkeypatch.delenv(SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, raising=False) + monkeypatch.setattr("config._document.PROJECT_ROOT", tmp_path) + + with pytest.raises(SettingsFileNotFoundError, match=r"tex-bot-deployment\.yaml"): + get_settings_file_path() + + @staticmethod + def test_default_location_is_used_when_no_environment_variable_is_set( + tmp_path: "Path", monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that the project root is searched when no location is configured.""" + monkeypatch.delenv(SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, raising=False) + monkeypatch.setattr("config._document.PROJECT_ROOT", tmp_path) + + DEFAULT_CONFIG_FILE_PATH: Final[Path] = tmp_path / "tex-bot-deployment.yaml" + DEFAULT_CONFIG_FILE_PATH.write_text(MINIMAL_CONFIG, encoding="utf-8") + + assert get_settings_file_path() == DEFAULT_CONFIG_FILE_PATH.resolve() + + +def test_temporary_file_is_written_alongside_its_destination(config_file: "Path") -> None: + """ + Test that the temporary file used while writing is created beside the destination. + + Renaming is only atomic within a single filesystem, which writing alongside the + destination guarantees. + """ + document: SettingsDocument = SettingsDocument.load(config_file) + + observed_temporary_paths: list[str] = [] + real_replace = os.replace + + def _recording_replace(src: object, dst: object) -> None: + observed_temporary_paths.append(str(src)) + real_replace(src, dst) # type: ignore[arg-type] + + with mock.patch("config._document.os.replace", _recording_replace): + document.write() + + assert len(observed_temporary_paths) == 1 + assert ( + os.path.dirname(observed_temporary_paths[0]) # noqa: PTH120 + == str(config_file.parent) + ) diff --git a/tests/config/test_logging.py b/tests/config/test_logging.py new file mode 100644 index 000000000..29c2bf84f --- /dev/null +++ b/tests/config/test_logging.py @@ -0,0 +1,205 @@ +"""Test suite for applying the logging configuration.""" + +import logging +from typing import TYPE_CHECKING + +import pytest +from discord_logging.handler import DiscordHandler + +from config._logging import ( + DISCORD_LOGGER_NAME, + LOGGER_NAME, + apply_logging_settings, +) +from config._schema import SettingsSchema + +from .conftest import VALID_BOT_TOKEN, VALID_MAIN_GUILD_ID, VALID_WEBHOOK_URL + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping, Sequence + from logging import Handler, Logger + from pathlib import Path + from typing import Final + + from config._schema import LoggingSettings + +__all__: "Sequence[str]" = () + + +REQUIRED_SETTINGS: "Final[Mapping[str, object]]" = { + "discord": {"bot-token": VALID_BOT_TOKEN, "main-guild-id": VALID_MAIN_GUILD_ID}, + "community-group": {"links": {}, "msl": {}}, +} + + +def _logging_settings(**logging_overrides: object) -> "LoggingSettings": + """Build the logging section of a validated configuration.""" + return SettingsSchema.model_validate( + {**REQUIRED_SETTINGS, "logging": logging_overrides} + ).logging + + +@pytest.fixture(autouse=True) +def _restore_loggers() -> "Iterator[None]": + """ + Restore both loggers to the state they were in before each test. + + Logging is process-wide, so without this a test applying a configuration would + leave its handlers attached for every test that ran afterwards. + """ + tex_bot_logger: Logger = logging.getLogger(LOGGER_NAME) + discord_logger: Logger = logging.getLogger(DISCORD_LOGGER_NAME) + + ORIGINAL_STATE: Final[Sequence[tuple[Logger, Sequence[Handler], int, bool]]] = tuple( + ( + single_logger, + tuple(single_logger.handlers), + single_logger.level, + single_logger.propagate, + ) + for single_logger in (tex_bot_logger, discord_logger) + ) + + yield + + single_logger: Logger + original_handlers: Sequence[Handler] + original_level: int + original_propagate: bool + for single_logger, original_handlers, original_level, original_propagate in ORIGINAL_STATE: + single_logger.handlers = list(original_handlers) + single_logger.setLevel(original_level) + single_logger.propagate = original_propagate + + +def _handlers_of_type(logger_name: str, handler_type: type) -> "Sequence[Handler]": + """Return every handler of the given type attached to the named logger.""" + return tuple( + handler + for handler in logging.getLogger(logger_name).handlers + if isinstance(handler, handler_type) + ) + + +class TestConsoleLogging: + """Test case for logging to the console output stream.""" + + @staticmethod + def test_console_log_level_is_applied() -> None: + """Test that the configured log level is set upon the logger.""" + apply_logging_settings(_logging_settings(console={"log-level": "WARNING"})) + + assert logging.getLogger(LOGGER_NAME).level == logging.WARNING + + @staticmethod + def test_a_console_handler_is_attached() -> None: + """Test that logs are emitted to the console output stream.""" + apply_logging_settings(_logging_settings()) + + assert len(_handlers_of_type(LOGGER_NAME, logging.StreamHandler)) == 1 + + @staticmethod + def test_applying_settings_repeatedly_does_not_accumulate_handlers() -> None: + """ + Test that applying the logging configuration many times attaches one handler. + + Handlers are replaced rather than added to, because an accumulated handler + would cause every log record to be emitted more than once. + """ + for log_level in ("DEBUG", "INFO", "WARNING", "ERROR"): + apply_logging_settings(_logging_settings(console={"log-level": log_level})) + + assert len(_handlers_of_type(LOGGER_NAME, logging.StreamHandler)) == 1 + + +class TestDiscordChannelLogging: + """Test case for relaying error logs to a Discord log channel.""" + + @staticmethod + def test_no_handler_is_attached_when_the_section_is_omitted() -> None: + """Test that omitting the Discord log-channel section attaches no handler.""" + apply_logging_settings(_logging_settings()) + + assert not _handlers_of_type(LOGGER_NAME, DiscordHandler) + + @staticmethod + def test_a_handler_is_attached_when_a_webhook_is_configured() -> None: + """Test that configuring a webhook URL relays logs to that Discord channel.""" + apply_logging_settings( + _logging_settings( + **{"discord-channel": {"webhook-url": VALID_WEBHOOK_URL, "log-level": "ERROR"}} + ) + ) + + DISCORD_HANDLERS: Final[Sequence[Handler]] = _handlers_of_type( + LOGGER_NAME, DiscordHandler + ) + + assert len(DISCORD_HANDLERS) == 1 + assert DISCORD_HANDLERS[0].level == logging.ERROR + + @staticmethod + def test_removing_the_webhook_detaches_the_handler() -> None: + """Test that removing the Discord log-channel section stops relaying logs.""" + apply_logging_settings( + _logging_settings(**{"discord-channel": {"webhook-url": VALID_WEBHOOK_URL}}) + ) + assert _handlers_of_type(LOGGER_NAME, DiscordHandler) + + apply_logging_settings(_logging_settings()) + + assert not _handlers_of_type(LOGGER_NAME, DiscordHandler) + + +class TestDiscordAPILogging: + """Test case for recording the logs emitted by the Discord API wrapper.""" + + @staticmethod + def test_no_handler_is_attached_when_disabled() -> None: + """Test that Discord API logs are not recorded unless they are enabled.""" + apply_logging_settings(_logging_settings(**{"discord-api": {"enabled": False}})) + + assert not _handlers_of_type(DISCORD_LOGGER_NAME, logging.FileHandler) + + @staticmethod + def test_a_file_handler_is_attached_when_enabled(tmp_path: "Path") -> None: + """Test that enabling Discord API logging writes them to the configured file.""" + LOG_FILE_PATH: Final[Path] = tmp_path / "discord.log" + + apply_logging_settings( + _logging_settings( + **{ + "discord-api": { + "enabled": True, + "log-level": "DEBUG", + "file-name": str(LOG_FILE_PATH), + } + } + ) + ) + + FILE_HANDLERS: Final[Sequence[Handler]] = _handlers_of_type( + DISCORD_LOGGER_NAME, logging.FileHandler + ) + + assert len(FILE_HANDLERS) == 1 + assert logging.getLogger(DISCORD_LOGGER_NAME).level == logging.DEBUG + + @staticmethod + def test_disabling_afterwards_detaches_the_handler(tmp_path: "Path") -> None: + """Test that disabling Discord API logging stops recording it.""" + apply_logging_settings( + _logging_settings( + **{ + "discord-api": { + "enabled": True, + "file-name": str(tmp_path / "discord.log"), + } + } + ) + ) + assert _handlers_of_type(DISCORD_LOGGER_NAME, logging.FileHandler) + + apply_logging_settings(_logging_settings(**{"discord-api": {"enabled": False}})) + + assert not _handlers_of_type(DISCORD_LOGGER_NAME, logging.FileHandler) diff --git a/tests/config/test_messages.py b/tests/config/test_messages.py new file mode 100644 index 000000000..f2b80de77 --- /dev/null +++ b/tests/config/test_messages.py @@ -0,0 +1,185 @@ +"""Test suite for loading the response messages TeX-Bot sends into Discord.""" + +import json +from typing import TYPE_CHECKING + +import pytest + +from config._messages import ( + MESSAGES_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, + MessagesAccessor, +) +from exceptions import ( + ImproperlyConfiguredError, + MessagesJSONFileMissingKeyError, + MessagesJSONFileValueError, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + from pathlib import Path + from typing import Final + + type MessagesWriter = "Callable[[object], Path]" + +__all__: "Sequence[str]" = () + + +VALID_MESSAGES: "Final[Mapping[str, object]]" = { + "welcome_messages": ["Welcome!", "Hello there!"], + "roles_messages": ["Get your roles here."], +} + + +@pytest.fixture() +def write_messages(tmp_path: "Path", monkeypatch: pytest.MonkeyPatch) -> "MessagesWriter": + """Return a callable writing the given messages & pointing TeX-Bot at them.""" + + def _write_messages(raw_messages: object) -> "Path": + messages_file_path: Path = tmp_path / "messages.json" + messages_file_path.write_text( + raw_messages if isinstance(raw_messages, str) else json.dumps(raw_messages), + encoding="utf-8", + ) + monkeypatch.setenv( + MESSAGES_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, str(messages_file_path) + ) + return messages_file_path + + return _write_messages + + +class TestLoading: + """Test case for reading the messages file.""" + + @staticmethod + def test_valid_messages_are_loaded(write_messages: "MessagesWriter") -> None: + """Test that both sets of messages are read from a valid file.""" + write_messages(VALID_MESSAGES) + messages: MessagesAccessor = MessagesAccessor() + + messages.reload() + + assert messages.is_loaded + assert messages.welcome_messages == frozenset({"Welcome!", "Hello there!"}) + assert messages.roles_messages == frozenset({"Get your roles here."}) + + @staticmethod + def test_accessing_messages_before_loading_is_refused() -> None: + """Test that reading messages before any are loaded is refused.""" + messages: MessagesAccessor = MessagesAccessor() + + assert not messages.is_loaded + + with pytest.raises(RuntimeError, match="before they have been loaded"): + _ = messages.welcome_messages + + @staticmethod + def test_a_missing_file_is_reported( + tmp_path: "Path", monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that naming a messages file that does not exist is reported clearly.""" + monkeypatch.setenv( + MESSAGES_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, str(tmp_path / "absent.json") + ) + + with pytest.raises(ImproperlyConfiguredError, match="must be a path"): + MessagesAccessor().reload() + + @staticmethod + def test_malformed_json_is_reported(write_messages: "MessagesWriter") -> None: + """Test that a file that is not valid JSON is reported clearly.""" + write_messages("{not valid json") + + with pytest.raises(ImproperlyConfiguredError, match="decoded"): + MessagesAccessor().reload() + + @staticmethod + @pytest.mark.parametrize("raw_messages", (["a", "list"], "a string", 42)) + def test_json_that_is_not_an_object_is_reported( + write_messages: "MessagesWriter", raw_messages: object + ) -> None: + """Test that a messages file not holding an object of message sets is rejected.""" + write_messages(raw_messages) + + with pytest.raises(ImproperlyConfiguredError, match="decoded"): + MessagesAccessor().reload() + + +class TestMessageSetValidation: + """Test case for the structure each set of messages must have.""" + + @staticmethod + @pytest.mark.parametrize("missing_key", ("welcome_messages", "roles_messages")) + def test_a_missing_message_set_is_reported( + write_messages: "MessagesWriter", missing_key: str + ) -> None: + """Test that omitting either set of messages is reported clearly.""" + write_messages( + {key: value for key, value in VALID_MESSAGES.items() if key != missing_key} + ) + + with pytest.raises(MessagesJSONFileMissingKeyError): + MessagesAccessor().reload() + + @staticmethod + @pytest.mark.parametrize("invalid_value", ([], None, 42, {})) + def test_an_empty_or_non_iterable_message_set_is_reported( + write_messages: "MessagesWriter", invalid_value: object + ) -> None: + """Test that a set of messages that holds no messages is rejected.""" + write_messages({**VALID_MESSAGES, "welcome_messages": invalid_value}) + + with pytest.raises(MessagesJSONFileValueError): + MessagesAccessor().reload() + + @staticmethod + def test_a_string_is_not_accepted_as_a_set_of_messages( + write_messages: "MessagesWriter", + ) -> None: + """ + Test that a single string is not treated as a set of messages. + + A string is iterable, so without an explicit check it would be accepted and + silently split into one message per character. + """ + write_messages({**VALID_MESSAGES, "welcome_messages": "Welcome!"}) + + with pytest.raises(MessagesJSONFileValueError): + MessagesAccessor().reload() + + +class TestFailedReloads: + """Test case for what happens when messages cannot be reloaded.""" + + @staticmethod + def test_a_failed_reload_leaves_previously_loaded_messages_intact( + write_messages: "MessagesWriter", + ) -> None: + """ + Test that failing to reload does not discard the messages already loaded. + + Both sets are read before either is stored, so a file that is only partly + valid cannot leave one set updated and the other stale. + """ + write_messages(VALID_MESSAGES) + messages: MessagesAccessor = MessagesAccessor() + messages.reload() + + write_messages({"welcome_messages": ["Still valid."]}) + + with pytest.raises(MessagesJSONFileMissingKeyError): + messages.reload() + + assert messages.welcome_messages == frozenset({"Welcome!", "Hello there!"}) + assert messages.roles_messages == frozenset({"Get your roles here."}) + + +def test_messages_are_deduplicated(write_messages: "MessagesWriter") -> None: + """Test that a message repeated within the file is only held once.""" + write_messages({**VALID_MESSAGES, "roles_messages": ["Same", "Same", "Different"]}) + messages: MessagesAccessor = MessagesAccessor() + + messages.reload() + + assert messages.roles_messages == frozenset({"Same", "Different"}) diff --git a/tests/config/test_reload.py b/tests/config/test_reload.py new file mode 100644 index 000000000..7e92f1282 --- /dev/null +++ b/tests/config/test_reload.py @@ -0,0 +1,248 @@ +"""Test suite for reloading configuration through the config package's public surface.""" + +import logging +from typing import TYPE_CHECKING + +import pytest + +import config +from config import SettingsValidationError +from config._accessor import SettingsAccessor + +from .conftest import MINIMAL_CONFIG, VALID_BOT_TOKEN + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from pathlib import Path + from typing import Final + + from config import ConfigReloadResult + + type ConfigWriter = "Callable[[str], Path]" + +__all__: "Sequence[str]" = () + + +CONFIG_WITH_TASK_SETTINGS: "Final[str]" = f"""\ +discord: + bot-token: {VALID_BOT_TOKEN} + main-guild-id: 1234567890123456789 +community-group: + links: {{}} + msl: {{}} +logging: + console: + log-level: INFO +reminders: + send-get-roles-reminders: + enabled: true + interval: 6h + delay: 1d16h +""" + + +@pytest.fixture(autouse=True) +def _unloaded_settings(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Replace the shared settings accessor with one holding no configuration. + + The accessor is a module-level singleton, so without this each test would begin + holding whatever the previous test had loaded, and would report changes relative + to it. Reloading is defined in terms of what changed, so that would make the + results depend upon the order the tests happened to run in. + """ + monkeypatch.setattr(config, "settings", SettingsAccessor()) + + +@pytest.fixture() +def configured( + write_config: "ConfigWriter", monkeypatch: pytest.MonkeyPatch +) -> "Callable[[str], ConfigReloadResult]": + """ + Return a callable writing the given configuration & reloading it. + + Reloading through the package's public surface reads whichever file the + `TEX_BOT_CONFIG_PATH` environment variable names, so it is pointed at a temporary + file for the duration of each test. + """ + + def _configure(raw_yaml: str) -> "ConfigReloadResult": + monkeypatch.setenv("TEX_BOT_CONFIG_PATH", str(write_config(raw_yaml))) + return config.reload_settings() + + return _configure + + +class TestReloadResult: + """Test case for what a reload reports back to its caller.""" + + @staticmethod + def test_reloading_an_unchanged_configuration_reports_nothing( + configured: "Callable[[str], ConfigReloadResult]", + ) -> None: + """Test that reloading without editing the file reports no changes.""" + configured(CONFIG_WITH_TASK_SETTINGS) + + assert configured(CONFIG_WITH_TASK_SETTINGS).changed_settings == set() + + @staticmethod + def test_a_changed_setting_is_reported( + configured: "Callable[[str], ConfigReloadResult]", + ) -> None: + """Test that editing one setting reports exactly that setting.""" + configured(CONFIG_WITH_TASK_SETTINGS) + + RESULT: Final[ConfigReloadResult] = configured( + CONFIG_WITH_TASK_SETTINGS.replace("log-level: INFO", "log-level: DEBUG") + ) + + assert RESULT.changed_settings == {"logging:console:log-level"} + + @staticmethod + def test_an_invalid_configuration_is_rejected( + configured: "Callable[[str], ConfigReloadResult]", + ) -> None: + """Test that a configuration failing validation is refused.""" + configured(CONFIG_WITH_TASK_SETTINGS) + + with pytest.raises(SettingsValidationError): + configured( + CONFIG_WITH_TASK_SETTINGS.replace("log-level: INFO", "log-level: NONSENSE") + ) + + @staticmethod + def test_an_invalid_configuration_leaves_the_previous_one_running( + configured: "Callable[[str], ConfigReloadResult]", + ) -> None: + """Test that a rejected reload does not disturb the running configuration.""" + configured(CONFIG_WITH_TASK_SETTINGS) + PREVIOUS_LOG_LEVEL: Final[object] = config.settings.logging.console.log_level + + with pytest.raises(SettingsValidationError): + configured( + CONFIG_WITH_TASK_SETTINGS.replace("log-level: INFO", "log-level: NONSENSE") + ) + + assert config.settings.logging.console.log_level == PREVIOUS_LOG_LEVEL + + +class TestRestartRequiredClassification: + """Test case for identifying which changes cannot take effect until a restart.""" + + @staticmethod + def test_a_live_setting_is_not_reported_as_needing_a_restart( + configured: "Callable[[str], ConfigReloadResult]", + ) -> None: + """Test that a setting read at the point of use needs no restart.""" + configured(CONFIG_WITH_TASK_SETTINGS) + + RESULT: Final[ConfigReloadResult] = configured( + CONFIG_WITH_TASK_SETTINGS.replace("log-level: INFO", "log-level: DEBUG") + ) + + assert RESULT.restart_required_settings == set() + + @staticmethod + @pytest.mark.parametrize( + ("original", "replacement", "expected_setting"), + ( + ( + "main-guild-id: 1234567890123456789", + "main-guild-id: 9876543210987654321", + "discord:main-guild-id", + ), + ( + "interval: 6h", + "interval: 30m", + "reminders:send-get-roles-reminders:interval", + ), + ( + "enabled: true", + "enabled: false", + "reminders:send-get-roles-reminders:enabled", + ), + ), + ) + def test_a_setting_fixed_at_start_up_is_reported_as_needing_a_restart( + configured: "Callable[[str], ConfigReloadResult]", + original: str, + replacement: str, + expected_setting: str, + ) -> None: + """Test that a setting which cannot be applied while running is flagged.""" + configured(CONFIG_WITH_TASK_SETTINGS) + + RESULT: Final[ConfigReloadResult] = configured( + CONFIG_WITH_TASK_SETTINGS.replace(original, replacement) + ) + + assert RESULT.restart_required_settings == {expected_setting} + + @staticmethod + def test_a_reminder_delay_does_not_require_a_restart( + configured: "Callable[[str], ConfigReloadResult]", + ) -> None: + """ + Test that changing how long to wait before a reminder needs no restart. + + Unlike the interval of the task that sends them, a delay is read from the + settings accessor at the moment it is used. + """ + configured(CONFIG_WITH_TASK_SETTINGS) + + RESULT: Final[ConfigReloadResult] = configured( + CONFIG_WITH_TASK_SETTINGS.replace("delay: 1d16h", "delay: 2d") + ) + + assert RESULT.changed_settings == {"reminders:send-get-roles-reminders:delay"} + assert RESULT.restart_required_settings == set() + + @staticmethod + def test_settings_needing_a_restart_are_a_subset_of_those_that_changed( + configured: "Callable[[str], ConfigReloadResult]", + ) -> None: + """Test that nothing is reported as needing a restart unless it actually changed.""" + RESULT: Final[ConfigReloadResult] = configured(CONFIG_WITH_TASK_SETTINGS) + + assert RESULT.restart_required_settings <= RESULT.changed_settings + + +class TestLoggingApplication: + """Test case for applying logging settings as part of a reload.""" + + @staticmethod + def test_changing_the_console_log_level_takes_effect( + configured: "Callable[[str], ConfigReloadResult]", + ) -> None: + """Test that a new console log level is applied to the logger immediately.""" + configured(CONFIG_WITH_TASK_SETTINGS) + + configured(CONFIG_WITH_TASK_SETTINGS.replace("log-level: INFO", "log-level: DEBUG")) + + assert logging.getLogger("TeX-Bot").level == logging.DEBUG + + @staticmethod + def test_reloading_repeatedly_does_not_accumulate_handlers( + configured: "Callable[[str], ConfigReloadResult]", + ) -> None: + """ + Test that reloading many times does not add a logging handler each time. + + Accumulating handlers would cause every log record to be emitted repeatedly. + """ + configured(CONFIG_WITH_TASK_SETTINGS) + HANDLER_COUNT_AFTER_FIRST_LOAD: Final[int] = len(logging.getLogger("TeX-Bot").handlers) + + for log_level in ("DEBUG", "WARNING", "ERROR", "INFO"): + configured( + CONFIG_WITH_TASK_SETTINGS.replace("log-level: INFO", f"log-level: {log_level}") + ) + + assert len(logging.getLogger("TeX-Bot").handlers) == HANDLER_COUNT_AFTER_FIRST_LOAD + + +def test_minimal_configuration_reloads( + configured: "Callable[[str], ConfigReloadResult]", +) -> None: + """Test that a configuration holding only the required settings can be reloaded.""" + assert configured(MINIMAL_CONFIG).changed_settings diff --git a/tests/config/test_schema.py b/tests/config/test_schema.py new file mode 100644 index 000000000..34ca781b6 --- /dev/null +++ b/tests/config/test_schema.py @@ -0,0 +1,469 @@ +"""Test suite for the settings schema.""" + +import datetime +from typing import TYPE_CHECKING + +import pytest +from pydantic import ValidationError + +from config import get_settings_metadata +from config._schema import LogLevel, SettingsSchema + +from .conftest import ( + DEFAULT_EASTER_EGG_PROBABILITY, + MINIMAL_CONFIG, + VALID_BOT_TOKEN, + VALID_MAIN_GUILD_ID, + VALID_WEBHOOK_URL, +) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from typing import Final + + from config import ConfigSettingMetadata + +__all__: "Sequence[str]" = () + + +REQUIRED_SETTINGS: "Final[Mapping[str, object]]" = { + "discord": {"bot-token": VALID_BOT_TOKEN, "main-guild-id": VALID_MAIN_GUILD_ID}, + "community-group": {"links": {}, "msl": {}}, +} + + +def _config(**overrides: object) -> "Mapping[str, object]": + """Build a valid raw configuration, with the given top-level sections replaced.""" + return {**REQUIRED_SETTINGS, **overrides} + + +class TestRequiredSettings: + """Test case for which settings a configuration must provide.""" + + @staticmethod + def test_minimal_configuration_is_valid() -> None: + """Test that a configuration holding only the required settings validates.""" + settings: SettingsSchema = SettingsSchema.model_validate(_config()) + + assert settings.discord.main_guild_id == VALID_MAIN_GUILD_ID + + @staticmethod + @pytest.mark.parametrize("missing_section", ("discord", "community-group")) + def test_missing_required_section_is_rejected(missing_section: str) -> None: + """Test that omitting a required section is rejected.""" + raw_settings: dict[str, object] = dict(REQUIRED_SETTINGS) + del raw_settings[missing_section] + + with pytest.raises(ValidationError, match="Field required"): + SettingsSchema.model_validate(raw_settings) + + @staticmethod + def test_unknown_setting_is_rejected() -> None: + """Test that a setting the schema does not declare is rejected.""" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + SettingsSchema.model_validate(_config(**{"not-a-real-setting": True})) + + @staticmethod + def test_every_optional_section_may_be_omitted() -> None: + """Test that omitting every optional section still produces usable defaults.""" + settings: SettingsSchema = SettingsSchema.model_validate(_config()) + + assert settings.logging.console.log_level == LogLevel.INFO + assert settings.commands.ping.easter_egg_probability == DEFAULT_EASTER_EGG_PROBABILITY + assert settings.reminders.send_get_roles_reminders.enabled is True + assert settings.auto_add_committee_to_threads is True + + @staticmethod + def test_discord_log_channel_section_is_optional() -> None: + """ + Test that the Discord log-channel section may be omitted from within `logging`. + + This section holds a required webhook URL, so it must be possible to configure + other logging destinations without providing one. + """ + settings: SettingsSchema = SettingsSchema.model_validate( + _config(logging={"console": {"log-level": "DEBUG"}}) + ) + + assert settings.logging.discord_channel is None + assert settings.logging.console.log_level == LogLevel.DEBUG + + @staticmethod + def test_discord_log_channel_requires_a_webhook_url() -> None: + """Test that providing the Discord log-channel section requires a webhook URL.""" + with pytest.raises(ValidationError, match="Field required"): + SettingsSchema.model_validate( + _config(logging={"discord-channel": {"log-level": "ERROR"}}) + ) + + +class TestDurationParsing: + """Test case for parsing the delay/interval strings used by time-based settings.""" + + @staticmethod + @pytest.mark.parametrize( + ("raw_duration", "expected_duration"), + ( + ("30s", datetime.timedelta(seconds=30)), + ("10m", datetime.timedelta(minutes=10)), + ("24h", datetime.timedelta(hours=24)), + ("2d", datetime.timedelta(days=2)), + ("1h30m", datetime.timedelta(hours=1, minutes=30)), + ("90m", datetime.timedelta(minutes=90)), + ("1d12h30m15s", datetime.timedelta(days=1, hours=12, minutes=30, seconds=15)), + ("0.5h", datetime.timedelta(minutes=30)), + ), + ) + def test_valid_durations_are_parsed( + raw_duration: str, expected_duration: datetime.timedelta + ) -> None: + """Test that a duration written largest-unit-first is parsed.""" + settings: SettingsSchema = SettingsSchema.model_validate( + _config(commands={"strike": {"timeout-duration": raw_duration}}) + ) + + assert settings.commands.strike.timeout_duration == expected_duration + + @staticmethod + @pytest.mark.parametrize( + "raw_duration", + ( + "30m1h", # NOTE: Units given smallest-first + "1h 30m", # NOTE: Whitespace between units + "", # NOTE: A zero-length duration would cause a task to spin + "PT1H30M", # NOTE: An ISO-8601 duration + "01:30:00", + "5", # NOTE: No unit given + "5x", # NOTE: An unknown unit + "-1h", + ), + ) + def test_invalid_durations_are_rejected(raw_duration: str) -> None: + """Test that anything outside the documented duration format is rejected.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config(commands={"strike": {"timeout-duration": raw_duration}}) + ) + + +class TestValueConstraints: + """Test case for the constraints applied to individual settings values.""" + + @staticmethod + @pytest.mark.parametrize("probability", (0, 0.5, 1)) + def test_easter_egg_probability_accepts_its_range(probability: float) -> None: + """Test that a probability within zero to one inclusive is accepted.""" + settings: SettingsSchema = SettingsSchema.model_validate( + _config(commands={"ping": {"easter-egg-probability": probability}}) + ) + + assert settings.commands.ping.easter_egg_probability == probability + + @staticmethod + @pytest.mark.parametrize("probability", (-0.1, 1.1, 100)) + def test_easter_egg_probability_rejects_values_outside_its_range( + probability: float, + ) -> None: + """Test that a probability outside zero to one inclusive is rejected.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config(commands={"ping": {"easter-egg-probability": probability}}) + ) + + @staticmethod + @pytest.mark.parametrize("guild_id", (10**16 - 1, 10**20, 0, -1)) + def test_invalid_discord_snowflake_is_rejected(guild_id: int) -> None: + """Test that an ID outside the range of a Discord snowflake is rejected.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + { + **REQUIRED_SETTINGS, + "discord": {"bot-token": VALID_BOT_TOKEN, "main-guild-id": guild_id}, + } + ) + + @staticmethod + @pytest.mark.parametrize( + "bot_token", + ("", "not-a-token", "short.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se7kKWs", VALID_BOT_TOKEN[:-1]), + ) + def test_invalid_bot_token_is_rejected(bot_token: str) -> None: + """Test that a value not matching the Discord bot-token format is rejected.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + { + **REQUIRED_SETTINGS, + "discord": {"bot-token": bot_token, "main-guild-id": VALID_MAIN_GUILD_ID}, + } + ) + + @staticmethod + def test_non_discord_webhook_url_is_rejected() -> None: + """Test that a URL that is not a Discord webhook is rejected.""" + with pytest.raises(ValidationError, match="Discord webhook URL"): + SettingsSchema.model_validate( + _config(logging={"discord-channel": {"webhook-url": "https://example.com"}}) + ) + + @staticmethod + def test_discord_webhook_url_is_accepted() -> None: + """Test that a Discord webhook URL is accepted.""" + settings: SettingsSchema = SettingsSchema.model_validate( + _config(logging={"discord-channel": {"webhook-url": VALID_WEBHOOK_URL}}) + ) + + assert settings.logging.discord_channel is not None + assert str(settings.logging.discord_channel.webhook_url).startswith( + "https://discord.com/api/webhooks/" + ) + + @staticmethod + def test_duplicate_values_within_a_unique_sequence_are_rejected() -> None: + """Test that repeating a value within a set of role names is rejected.""" + with pytest.raises(ValidationError, match="unique"): + SettingsSchema.model_validate( + _config(commands={"stats": {"displayed-roles": ["Member", "Member"]}}) + ) + + +class TestValueNormalisation: + """Test case for values that are accepted in more than one form.""" + + @staticmethod + @pytest.mark.parametrize( + "raw_log_level", ("DEBUG", "debug", " Debug ", "-debug-", "debug.") + ) + def test_log_level_is_matched_case_and_punctuation_agnostically( + raw_log_level: str, + ) -> None: + """Test that a log level is recognised regardless of case or stray punctuation.""" + settings: SettingsSchema = SettingsSchema.model_validate( + _config(logging={"console": {"log-level": raw_log_level}}) + ) + + assert settings.logging.console.log_level == LogLevel.DEBUG + + @staticmethod + def test_unknown_log_level_is_rejected() -> None: + """Test that a value that is not a log level is rejected.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config(logging={"console": {"log-level": "NONSENSE"}}) + ) + + @staticmethod + @pytest.mark.parametrize( + ("raw_flag", "expected_flag"), + ( + ("once", "once"), + ("interval", "interval"), + (True, "once"), + ("true", "once"), + ("yes", "once"), + (False, False), + ("false", False), + ("no", False), + ), + ) + def test_send_introduction_reminders_flag_is_normalised( + raw_flag: object, expected_flag: object + ) -> None: + """Test that boolean-like values are treated as their canonical equivalent.""" + settings: SettingsSchema = SettingsSchema.model_validate( + _config(reminders={"send-introduction-reminders": {"enabled": raw_flag}}) + ) + + assert settings.reminders.send_introduction_reminders.enabled == expected_flag + + @staticmethod + def test_lookback_days_is_exposed_as_a_period() -> None: + """Test that the statistics lookback is usable as a length of time.""" + settings: SettingsSchema = SettingsSchema.model_validate( + _config(commands={"stats": {"lookback-days": 14}}) + ) + + assert settings.commands.stats.lookback_period == datetime.timedelta(days=14) + + +class TestSecrecy: + """Test case for keeping secret settings out of logs & error messages.""" + + @staticmethod + def test_secret_values_are_masked_when_displayed() -> None: + """Test that a secret value is not revealed by displaying the settings.""" + settings: SettingsSchema = SettingsSchema.model_validate(_config()) + + assert VALID_BOT_TOKEN not in repr(settings) + assert VALID_BOT_TOKEN not in str(settings.discord.bot_token) + + @staticmethod + def test_secret_values_remain_readable_when_explicitly_requested() -> None: + """Test that a secret value can still be read where it is genuinely needed.""" + settings: SettingsSchema = SettingsSchema.model_validate(_config()) + + assert settings.discord.bot_token.get_secret_value() == VALID_BOT_TOKEN + + @staticmethod + def test_rejected_secret_value_is_absent_from_scrubbed_errors() -> None: + """Test that an invalid secret is not echoed back by a scrubbed error message.""" + invalid_token: Final[str] = "SUPER-SECRET-BUT-INVALID" # noqa: S105 + + with pytest.raises(ValidationError) as validation_error: + SettingsSchema.model_validate( + { + **REQUIRED_SETTINGS, + "discord": { + "bot-token": invalid_token, + "main-guild-id": VALID_MAIN_GUILD_ID, + }, + } + ) + + assert invalid_token not in str( + validation_error.value.errors(include_input=False, include_url=False) + ) + + +class TestSettingsMetadata: + """Test case for the metadata describing each setting to a human.""" + + @staticmethod + def test_every_setting_is_described() -> None: + """Test that no setting is missing its help text.""" + undescribed_settings: Sequence[str] = [ + settings_name + for settings_name, metadata in get_settings_metadata().items() + if not metadata.description + ] + + assert not undescribed_settings + + @staticmethod + def test_settings_requiring_a_restart_are_exactly_those_that_cannot_be_applied() -> None: + """ + Test that only the settings which genuinely cannot be applied need a restart. + + Flagging a setting that could be applied would send committee members away to + restart TeX-Bot for no reason, and failing to flag one that cannot would leave + them believing a change had taken effect when it had not. + """ + EXPECTED_RESTART_REQUIRED_SETTINGS: Final[frozenset[str]] = frozenset( + { + # NOTE: Used to connect to Discord & to populate the shortcut accessors. + "discord:bot-token", + "discord:main-guild-id", + # NOTE: Fixed when each recurring task is created during start-up. + "community-group:msl:auto-cookie-checking:enabled", + "community-group:msl:auto-cookie-checking:interval", + "reminders:send-introduction-reminders:enabled", + "reminders:send-introduction-reminders:interval", + "reminders:send-get-roles-reminders:enabled", + "reminders:send-get-roles-reminders:interval", + } + ) + + assert ( + frozenset( + settings_name + for settings_name, metadata in get_settings_metadata().items() + if metadata.requires_restart + ) + == EXPECTED_RESTART_REQUIRED_SETTINGS + ) + + @staticmethod + def test_delays_do_not_require_a_restart() -> None: + """ + Test that the delay before a reminder is sent takes effect without a restart. + + Unlike the interval of the task that sends them, a delay is read from the + settings accessor at the moment it is used. + """ + SETTINGS_METADATA: Final[Mapping[str, ConfigSettingMetadata]] = get_settings_metadata() + + assert not SETTINGS_METADATA[ + "reminders:send-introduction-reminders:delay" + ].requires_restart + assert not SETTINGS_METADATA[ + "reminders:send-get-roles-reminders:delay" + ].requires_restart + + @staticmethod + def test_every_secret_setting_is_flagged() -> None: + """Test that each setting holding a credential is marked as secret.""" + EXPECTED_SECRET_SETTINGS: Final[frozenset[str]] = frozenset( + { + "discord:bot-token", + "community-group:msl:auth-cookie", + "logging:discord-channel:webhook-url", + } + ) + + assert ( + frozenset( + settings_name + for settings_name, metadata in get_settings_metadata().items() + if metadata.secret + ) + == EXPECTED_SECRET_SETTINGS + ) + + @staticmethod + def test_metadata_covers_every_configurable_setting() -> None: + """Test that the metadata describes exactly the settings that can be configured.""" + from config._accessor import _flatten_settings # noqa: PLC0415 + + SETTINGS_NAMES: Final[frozenset[str]] = frozenset( + _flatten_settings(SettingsSchema.model_validate(_config())) + ) + + # NOTE: An omitted optional section collapses to a single key, so the metadata + # describes the sections within it that the flattened settings cannot show. + assert SETTINGS_NAMES - frozenset(get_settings_metadata()) == { + "logging:discord-channel" + } + + +def test_minimal_config_fixture_matches_the_required_settings() -> None: + """Test that the shared minimal configuration holds exactly what the schema requires.""" + assert "bot-token" in MINIMAL_CONFIG + assert "main-guild-id" in MINIMAL_CONFIG + + +class TestNonStringValues: + """Test case for values given as the wrong type entirely.""" + + @staticmethod + @pytest.mark.parametrize("raw_duration", (30, 1.5, True, None, ["1h"])) + def test_a_duration_that_is_not_a_string_is_rejected(raw_duration: object) -> None: + """Test that a duration must be written as a string, not a bare number.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config(commands={"strike": {"timeout-duration": raw_duration}}) + ) + + @staticmethod + @pytest.mark.parametrize("raw_log_level", (10, None, ["DEBUG"])) + def test_a_log_level_that_is_not_a_string_is_rejected(raw_log_level: object) -> None: + """Test that a log level must be written as one of its names.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config(logging={"console": {"log-level": raw_log_level}}) + ) + + @staticmethod + @pytest.mark.parametrize("raw_flag", (42, None, ["once"])) + def test_an_unrecognised_reminders_flag_is_rejected(raw_flag: object) -> None: + """Test that a send-introduction-reminders value outside its options is rejected.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config(reminders={"send-introduction-reminders": {"enabled": raw_flag}}) + ) + + @staticmethod + def test_a_webhook_url_that_is_not_a_url_is_rejected() -> None: + """Test that the Discord log-channel webhook must be a URL.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config(logging={"discord-channel": {"webhook-url": 42}}) + ) From 9480e1369096f697821c7c14161d37167a77e69a Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:40:16 +0100 Subject: [PATCH 22/33] Cover the configuration package's surprising behaviours with tests Reviewing every NOTE left within the configuration package found several behaviours that nothing exercised, & five defects amongst them: * A zero-length interval (`0s`) was accepted, despite the empty string being rejected to prevent exactly that. Each interval becomes the loop period of a recurring task, so `interval: 0s` produced a task that ran continuously without ever pausing. Intervals are now declared as `PositiveTimeDelta`; a delay of zero remains meaningful & is unaffected. * `send-introduction-reminders: 0` was accepted as `false`, while the `1` its author would pair it with was rejected. * A JSON object was accepted as a set of response messages, & silently reduced to its own keys, discarding every message it held. * The example deployment configuration did not validate: its placeholder values for optional settings were rejected, so anybody copying it & filling in the two required values could not start TeX-Bot at all. * `SlashCommandGroup.command()` was stubbed with an unsolvable type variable, resolving to `Never`, which removed every command within a group from type-checking entirely. Also adds the regression tests that the previously-fixed `/config reload` error handling never had, along with tests for the round-trip line width, the `\Z` pattern anchors, the missing-key sentinel used to detect changes, logger propagation, & the constraints upon each individual setting. Each new test guarding a NOTE was verified by reverting the behaviour it describes & confirming that it, & only it, failed. --- config/_messages.py | 10 +- config/_schema.py | 41 ++++- stubs/discord/commands/core.pyi | 23 ++- tests/config/test_accessor.py | 53 +++++++ tests/config/test_command.py | 256 ++++++++++++++++++++++++++++++++ tests/config/test_document.py | 127 ++++++++++++++++ tests/config/test_logging.py | 59 ++++++++ tests/config/test_messages.py | 27 ++++ tests/config/test_schema.py | 215 ++++++++++++++++++++++++++- tex-bot-deployment.example.yaml | 32 ++-- 10 files changed, 816 insertions(+), 27 deletions(-) create mode 100644 tests/config/test_command.py diff --git a/config/_messages.py b/config/_messages.py index 25d8e0147..61767c256 100644 --- a/config/_messages.py +++ b/config/_messages.py @@ -8,7 +8,6 @@ import json import os -from collections.abc import Iterable from pathlib import Path from typing import TYPE_CHECKING @@ -83,14 +82,15 @@ def _get_message_set(raw_messages: "Mapping[str, object]", key: str) -> frozense value: object = raw_messages[key] - KEY_IS_VALID: Final[bool] = bool( - isinstance(value, Iterable) and not isinstance(value, (str, bytes)) and value - ) + # NOTE: Requiring a list (rather than merely something iterable) also rejects a JSON + # object, which would otherwise be accepted & silently iterated as its own keys, + # and a bare string, which would be split into one message per character. + KEY_IS_VALID: Final[bool] = isinstance(value, list) and bool(value) if not KEY_IS_VALID: raise MessagesJSONFileValueError(dict_key=key, invalid_value=value) if TYPE_CHECKING: - assert isinstance(value, Iterable) + assert isinstance(value, list) return frozenset(str(single_message) for single_message in value) diff --git a/config/_schema.py b/config/_schema.py index 853958336..046018cf2 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -96,7 +96,16 @@ def _parse_send_introduction_reminders_flag(value: object) -> object: return "once" if value else False if not isinstance(value, str): - return value + # NOTE: Pydantic's `Literal` would otherwise accept `0` as `False` (though never + # `1` as `once`), so `enabled: 0` would silently disable introduction reminders + # while the `enabled: 1` its author would pair it with was rejected. + NON_STRING_FLAG_MESSAGE: str = ( + "Value should be 'once', 'interval', or a boolean ('true'/'false')" + ) + # NOTE: Deliberately a `ValueError` despite describing a wrong type: Pydantic + # converts only `ValueError` & `AssertionError` into validation errors, so a + # `TypeError` would propagate uncaught & abandon the whole reload. + raise ValueError(NON_STRING_FLAG_MESSAGE) # noqa: TRY004 NORMALISED_VALUE: str = value.lower().strip() @@ -132,7 +141,8 @@ def _parse_time_delta(value: object) -> object: NOTE: A further deviation from that previous implementation: an empty string is rejected rather than silently parsed as a zero-length duration. - A zero-length interval would cause any task looping upon it to spin without pausing. + Durations that may not be zero-length are declared as `PositiveTimeDelta`; + this parser itself accepts a zero-length duration written explicitly (`0s`). """ if isinstance(value, datetime.timedelta): return value @@ -172,6 +182,22 @@ def _parse_time_delta(value: object) -> object: ) +def _ensure_positive_time_delta(value: datetime.timedelta) -> datetime.timedelta: + """ + Ensure the given duration is longer than zero. + + A zero-length interval would cause the recurring task looping upon it to run + continuously, without ever pausing between one execution & the next. + """ + if value <= datetime.timedelta(0): + NON_POSITIVE_TIME_DELTA_MESSAGE: str = ( + "Value should be a delay/interval string describing a duration longer than zero" + ) + raise ValueError(NON_POSITIVE_TIME_DELTA_MESSAGE) + + return value + + def _ensure_discord_webhook_url(value: HttpUrl) -> HttpUrl: """Ensure the given URL refers to a Discord webhook.""" if not str(value).startswith("https://discord.com/api/webhooks/"): @@ -209,6 +235,11 @@ def _ensure_unique(value: tuple[str, ...]) -> tuple[str, ...]: type TimeDelta = Annotated[datetime.timedelta, BeforeValidator(_parse_time_delta)] +type PositiveTimeDelta = Annotated[ + datetime.timedelta, + BeforeValidator(_parse_time_delta), + AfterValidator(_ensure_positive_time_delta), +] type UniqueStrSequence = Annotated[tuple[str, ...], AfterValidator(_ensure_unique)] type DiscordWebhookURL = Annotated[HttpUrl, AfterValidator(_ensure_discord_webhook_url)] type DiscordSnowflake = Annotated[int, Field(ge=10**16, lt=10**20)] @@ -384,7 +415,7 @@ class AutoCookieCheckingSettings(_BaseSettingsSchema): # type: ignore[explicit- ), json_schema_extra={"requires_restart": True, "secret": False}, ) - interval: TimeDelta = Field( + interval: PositiveTimeDelta = Field( default=datetime.timedelta(minutes=10), description="The interval of time between checking the MSL authentication cookie.", json_schema_extra={"requires_restart": True, "secret": False}, @@ -601,7 +632,7 @@ class SendIntroductionRemindersSettings(_BaseSettingsSchema): # type: ignore[ex ), json_schema_extra={"requires_restart": False, "secret": False}, ) - interval: TimeDelta = Field( + interval: PositiveTimeDelta = Field( default=datetime.timedelta(hours=6), description=( "The interval of time between sending out reminders to Discord members " @@ -633,7 +664,7 @@ class ReminderSettings(_BaseSettingsSchema): # type: ignore[explicit-any] ), json_schema_extra={"requires_restart": False, "secret": False}, ) - interval: TimeDelta = Field( + interval: PositiveTimeDelta = Field( default=datetime.timedelta(hours=6), description=( "The interval of time between checking for Discord members " diff --git a/stubs/discord/commands/core.pyi b/stubs/discord/commands/core.pyi index e194a0b73..b317daa42 100644 --- a/stubs/discord/commands/core.pyi +++ b/stubs/discord/commands/core.pyi @@ -1,7 +1,8 @@ -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Coroutine, Sequence __all__: Sequence[str] = ( "ApplicationCommand", + "CommandCallback", "MessageCommand", "SlashCommand", "SlashCommandGroup", @@ -13,7 +14,15 @@ __all__: Sequence[str] = ( "user_command", ) -from typing import override +from typing import Protocol, overload, override + +# NOTE: The undecorated function that a command runs when it is invoked. +class CommandCallback(Protocol): + __name__: str + + def __call__( + self, *args: object, **kwargs: object + ) -> Coroutine[object, object, None]: ... def slash_command[**P]( *, @@ -35,6 +44,7 @@ def command[**P]( class ApplicationCommand: qualified_name: str + callback: CommandCallback class SlashCommand(ApplicationCommand): ... class UserCommand(ApplicationCommand): ... @@ -43,6 +53,13 @@ class MessageCommand(ApplicationCommand): ... class SlashCommandGroup(ApplicationCommand): @override def __init__(self, name: str, description: str) -> None: ... + # NOTE: Overloaded because omitting `cls` leaves `T` unsolvable, which resolves to + # `Never` & silently removes every command within a group from type-checking. + @overload + def command[**P]( + self, *, name: str, description: str + ) -> Callable[[Callable[P, Awaitable[None]]], SlashCommand]: ... + @overload def command[**P, T: ApplicationCommand]( - self, cls: type[T] = ..., *, name: str, description: str + self, cls: type[T], *, name: str, description: str ) -> Callable[[Callable[P, Awaitable[None]]], T]: ... diff --git a/tests/config/test_accessor.py b/tests/config/test_accessor.py index 55ac56924..4ac4d1ed1 100644 --- a/tests/config/test_accessor.py +++ b/tests/config/test_accessor.py @@ -151,6 +151,59 @@ def test_an_appearing_section_reports_the_settings_within_it( assert "logging:discord-channel:webhook-url" in CHANGED_SETTINGS assert settings.logging.discord_channel is not None + @staticmethod + def test_a_disappearing_section_reports_the_settings_it_removed( + write_config: "ConfigWriter", + ) -> None: + """ + Test that removing an optional section reports the settings it took with it. + + A section that is present is expanded into the settings within it, whereas one + that is absent collapses to a single empty value under its own name. Comparing + one configuration against the other must therefore distinguish a key that is + missing from a key that is present but holds nothing, or removing the section + would appear to change nothing at all. + """ + settings: SettingsAccessor = SettingsAccessor() + settings.reload( + write_config( + f"{MINIMAL_CONFIG}" + f"logging:\n" + f" discord-channel:\n" + f" webhook-url: {VALID_WEBHOOK_URL}\n" + ) + ) + + CHANGED_SETTINGS: Final[AbstractSet[str]] = settings.reload( + write_config(MINIMAL_CONFIG) + ) + + assert "logging:discord-channel:webhook-url" in CHANGED_SETTINGS + assert "logging:discord-channel" in CHANGED_SETTINGS + assert settings.logging.discord_channel is None + + @staticmethod + def test_a_changed_secret_is_detected(write_config: "ConfigWriter") -> None: + """ + Test that replacing a secret value is reported as a change. + + A secret is held as an opaque object rather than as plain text, so a comparison + that fell back to identity would report every token as unchanged & leave the + committee member who rotated it with no warning that a restart is needed. + """ + settings: SettingsAccessor = SettingsAccessor() + settings.reload(write_config(MINIMAL_CONFIG)) + + assert settings.reload(write_config(MINIMAL_CONFIG)) == set() + + CHANGED_SETTINGS: Final[AbstractSet[str]] = settings.reload( + write_config( + MINIMAL_CONFIG.replace(VALID_BOT_TOKEN, f"{VALID_BOT_TOKEN[:-4]}wxyz") + ) + ) + + assert {"discord:bot-token"} == CHANGED_SETTINGS + class TestFailedReloads: """Test case for what happens when a configuration cannot be loaded.""" diff --git a/tests/config/test_command.py b/tests/config/test_command.py new file mode 100644 index 000000000..00f49bc30 --- /dev/null +++ b/tests/config/test_command.py @@ -0,0 +1,256 @@ +"""Test suite for the "/config" command group.""" + +import asyncio +from typing import TYPE_CHECKING, cast + +import pytest + +import config +from config._accessor import SettingsAccessor +from config._document import SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME + +from .conftest import MINIMAL_CONFIG, VALID_BOT_TOKEN + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Sequence + from pathlib import Path + from typing import Final + + from cogs.config import ConfigCommandsCog + from utils import TeXBot + + type ConfigReloadRunner = "Callable[[str | None], Sequence[str]]" + +__all__: "Sequence[str]" = () + + +class _RecordingApplicationContext: + """A stand-in for the Discord context that a slash command is invoked with.""" + + def __init__(self) -> None: + """Initialise a context that has been responded to with nothing so far.""" + self.responses: list[str] = [] + + async def defer(self, *, ephemeral: bool = False) -> None: + """Record that the command asked for more time to respond.""" + + async def respond(self, message: str, *, ephemeral: bool = False) -> None: # noqa: ARG002 + """Record a response sent back to the committee member that ran the command.""" + self.responses.append(message) + + +@pytest.fixture(scope="module") +def config_commands_cog( + tmp_path_factory: pytest.TempPathFactory, +) -> "Iterator[type[ConfigCommandsCog]]": + """ + Return the cog class defining the `/config` command group. + + Importing any cog loads every other one alongside it, several of which read the + configuration as they are imported, so a valid configuration must already be loaded + before the import can succeed at all. + """ + with pytest.MonkeyPatch.context() as module_monkeypatch: + CONFIG_FILE_PATH: Final[Path] = ( + tmp_path_factory.mktemp("config-command") / "tex-bot-deployment.yaml" + ) + CONFIG_FILE_PATH.write_text(MINIMAL_CONFIG, encoding="utf-8") + module_monkeypatch.setenv( + SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, str(CONFIG_FILE_PATH) + ) + config.reload_settings() + + from cogs.config import ConfigCommandsCog # noqa: PLC0415 + + yield ConfigCommandsCog + + +@pytest.fixture() +def run_config_reload( + config_commands_cog: "type[ConfigCommandsCog]", + write_config: "Callable[[str], Path]", + tmp_path: "Path", + monkeypatch: pytest.MonkeyPatch, +) -> "ConfigReloadRunner": + """ + Return a callable running the `/config reload` command against the given file. + + Passing `None` in place of a configuration runs the command with no configuration + file present at all. Each test begins from an accessor holding no configuration, so + that what the command reports does not depend upon what the previous test loaded. + """ + monkeypatch.setattr(config, "settings", SettingsAccessor()) + + # NOTE: The reload command never reaches for the running bot, so it does not need + # a connection to Discord in order to be exercised. + cog: ConfigCommandsCog = config_commands_cog(bot=cast("TeXBot", None)) + + def _run_config_reload(raw_yaml: str | None) -> "Sequence[str]": + monkeypatch.setenv( + SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, + str( + write_config(raw_yaml) + if raw_yaml is not None + else tmp_path / "no-such-file.yaml" + ), + ) + + context: _RecordingApplicationContext = _RecordingApplicationContext() + asyncio.run(config_commands_cog.reload.callback(cog, context)) + + return context.responses + + return _run_config_reload + + +class TestSuccessfulReloads: + """Test case for reloading a configuration that can be applied.""" + + @staticmethod + def test_the_settings_that_changed_are_reported( + run_config_reload: "ConfigReloadRunner", + ) -> None: + """Test that a successful reload names the settings it applied.""" + run_config_reload(MINIMAL_CONFIG) + + RESPONSES: Final[Sequence[str]] = run_config_reload( + f"{MINIMAL_CONFIG}logging:\n console:\n log-level: DEBUG\n" + ) + + assert len(RESPONSES) == 1 + assert "Reloaded the configuration file" in RESPONSES[0] + assert "`logging:console:log-level`" in RESPONSES[0] + + @staticmethod + def test_an_unchanged_configuration_is_reported( + run_config_reload: "ConfigReloadRunner", + ) -> None: + """Test that reloading without editing the file says so, rather than nothing.""" + run_config_reload(MINIMAL_CONFIG) + + RESPONSES: Final[Sequence[str]] = run_config_reload(MINIMAL_CONFIG) + + assert len(RESPONSES) == 1 + assert "has not changed" in RESPONSES[0] + + @staticmethod + def test_a_very_long_list_of_changes_is_truncated( + run_config_reload: "ConfigReloadRunner", + ) -> None: + """ + Test that reporting a great many changes does not produce an endless message. + + The very first reload reports every setting as changed, which is far more than + can be listed within a single Discord message. + """ + from cogs.config import MAXIMUM_LISTED_SETTINGS # noqa: PLC0415 + + RESPONSES: Final[Sequence[str]] = run_config_reload(MINIMAL_CONFIG) + + CHANGED_SETTINGS_LIST: Final[str] = RESPONSES[0].split(":warning:")[0] + + assert "- _...and " in CHANGED_SETTINGS_LIST + assert CHANGED_SETTINGS_LIST.count("\n- `") == MAXIMUM_LISTED_SETTINGS + + +class TestRestartRequiredWarning: + """Test case for warning that a change cannot take effect until a restart.""" + + @staticmethod + def test_a_setting_fixed_at_start_up_is_called_out( + run_config_reload: "ConfigReloadRunner", + ) -> None: + """Test that changing a setting which needs a restart says so explicitly.""" + run_config_reload(MINIMAL_CONFIG) + + RESPONSES: Final[Sequence[str]] = run_config_reload( + MINIMAL_CONFIG.replace("1234567890123456789", "9876543210987654321") + ) + + assert "must be restarted" in RESPONSES[0] + assert "`discord:main-guild-id`" in RESPONSES[0] + + @staticmethod + def test_a_setting_applied_immediately_is_not_called_out( + run_config_reload: "ConfigReloadRunner", + ) -> None: + """Test that a change taking effect at once does not ask for a restart.""" + run_config_reload(MINIMAL_CONFIG) + + RESPONSES: Final[Sequence[str]] = run_config_reload( + f"{MINIMAL_CONFIG}logging:\n console:\n log-level: DEBUG\n" + ) + + assert "must be restarted" not in RESPONSES[0] + + +class TestRejectedReloads: + """Test case for reporting a configuration that could not be loaded.""" + + @staticmethod + def test_invalid_settings_are_reported_without_being_applied( + run_config_reload: "ConfigReloadRunner", + ) -> None: + """Test that a configuration failing validation is refused & explained.""" + run_config_reload(MINIMAL_CONFIG) + + RESPONSES: Final[Sequence[str]] = run_config_reload( + f"{MINIMAL_CONFIG}logging:\n console:\n log-level: NONSENSE\n" + ) + + assert "was **not** loaded" in RESPONSES[0] + assert "invalid settings" in RESPONSES[0] + assert config.settings.logging.console.log_level == "INFO" + + @staticmethod + def test_a_rejected_secret_is_not_echoed_back( + run_config_reload: "ConfigReloadRunner", + ) -> None: + """Test that an invalid token is not quoted back into a Discord channel.""" + INVALID_TOKEN: Final[str] = "NOT-A-REAL-TOKEN-BUT-STILL-SECRET" # noqa: S105 + + run_config_reload(MINIMAL_CONFIG) + + RESPONSES: Final[Sequence[str]] = run_config_reload( + MINIMAL_CONFIG.replace(VALID_BOT_TOKEN, INVALID_TOKEN) + ) + + assert "was **not** loaded" in RESPONSES[0] + assert INVALID_TOKEN not in RESPONSES[0] + + @staticmethod + def test_a_missing_configuration_file_is_reported( + run_config_reload: "ConfigReloadRunner", + ) -> None: + """ + Test that running the command with no configuration file is reported clearly. + + Failing to find the file does not raise an operating-system error, so catching + one is not enough to keep this from surfacing as an unhandled exception. + """ + run_config_reload(MINIMAL_CONFIG) + + RESPONSES: Final[Sequence[str]] = run_config_reload(None) + + assert len(RESPONSES) == 1 + assert "could not be read" in RESPONSES[0] + + @staticmethod + @pytest.mark.parametrize( + "raw_yaml", ("discord:\n bot-token: [unclosed\n", "", "just-a-string\n") + ) + def test_an_unreadable_configuration_file_is_reported( + run_config_reload: "ConfigReloadRunner", raw_yaml: str + ) -> None: + """ + Test that a file that cannot be parsed at all is reported clearly. + + Nor does failing to parse the file raise an operating-system error, so this too + would otherwise surface as an unhandled exception rather than as an explanation. + """ + run_config_reload(MINIMAL_CONFIG) + + RESPONSES: Final[Sequence[str]] = run_config_reload(raw_yaml) + + assert len(RESPONSES) == 1 + assert "could not be read" in RESPONSES[0] diff --git a/tests/config/test_document.py b/tests/config/test_document.py index 57189f926..84a8e7df8 100644 --- a/tests/config/test_document.py +++ b/tests/config/test_document.py @@ -14,6 +14,7 @@ get_settings_file_path, ) from config._document import ( + PROJECT_ROOT, SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, ) from config._schema import SettingsSchema @@ -118,6 +119,32 @@ def test_comments_survive_a_changed_value(write_config: "ConfigWriter") -> None: assert "# A trailing comment." in NEW_FILE_CONTENTS assert "# Another trailing comment." in NEW_FILE_CONTENTS + @staticmethod + def test_a_long_value_is_not_re_wrapped(write_config: "ConfigWriter") -> None: + """ + Test that writing a file back does not fold its long values onto extra lines. + + A YAML writer wraps at its configured line width by default, which would rewrite + untouched sections of the file & bury the one setting that actually changed + within a diff full of spurious changes. + """ + LONG_VALUE: Final[str] = " ".join(["a very long channel name"] * 12) + + config_file_path: Path = write_config( + f"{MINIMAL_CONFIG}commands:\n" + f" strike:\n" + f" reported-message-destination-channel: {LONG_VALUE}\n" + ) + document: SettingsDocument = SettingsDocument.load(config_file_path) + + document.raw["community-group"]["full-name"] = "CompSoc" + document.write() + + assert ( + f"reported-message-destination-channel: {LONG_VALUE}" + in config_file_path.read_text(encoding="utf-8") + ) + @staticmethod def test_written_file_can_be_loaded_again(write_config: "ConfigWriter") -> None: """Test that a rewritten configuration file is still valid.""" @@ -311,6 +338,51 @@ def test_line_numbers_of_absent_settings_are_not_invented( assert document.line_number_of(["not-a-section", "not-a-setting"]) is None + @staticmethod + def test_an_error_within_a_list_is_reported_against_the_list( + write_config: "ConfigWriter", + ) -> None: + """ + Test that a rejected entry of a list is reported against the list holding it. + + Pydantic identifies such an entry by its position, so the reported key path ends + with a number rather than a name. Individual entries are not resolved to their + own lines, so the line of the list they belong to is reported instead. + """ + document: SettingsDocument = SettingsDocument.load( + write_config( + f"{MINIMAL_CONFIG}" + f"commands:\n" + f" stats:\n" + f" displayed-roles:\n" + f" - Committee\n" + f" - [a nested list]\n" + ) + ) + + with pytest.raises(ValidationError) as validation_error: + SettingsSchema.model_validate(document.raw) + + FORMATTED_ERROR: Final[str] = document.format_validation_error(validation_error.value) + + assert "commands:stats:displayed-roles:1" in FORMATTED_ERROR + assert "tex-bot-deployment.yaml:10" in FORMATTED_ERROR + + @staticmethod + def test_an_error_with_no_location_is_reported_against_the_whole_file( + config_file: "Path", + ) -> None: + """Test that a failure belonging to no particular setting still names the file.""" + document: SettingsDocument = SettingsDocument.load(config_file) + + with pytest.raises(ValidationError) as validation_error: + SettingsSchema.model_validate("not a mapping of settings at all") + + FORMATTED_ERROR: Final[str] = document.format_validation_error(validation_error.value) + + assert "(whole file)" in FORMATTED_ERROR + assert "tex-bot-deployment.yaml" in FORMATTED_ERROR + class TestFileDiscovery: """Test case for locating the configuration file.""" @@ -361,6 +433,61 @@ def test_default_location_is_used_when_no_environment_variable_is_set( assert get_settings_file_path() == DEFAULT_CONFIG_FILE_PATH.resolve() +class TestExampleConfiguration: + """Test case for the example configuration file shipped alongside TeX-Bot.""" + + @staticmethod + def _example_config_with_required_settings_filled_in() -> str: + """Return the example configuration, with its two required values supplied.""" + RAW_EXAMPLE_CONFIG: Final[str] = ( + PROJECT_ROOT / "tex-bot-deployment.example.yaml" + ).read_text(encoding="utf-8") + + return RAW_EXAMPLE_CONFIG.replace( + 'bot-token: ""', f"bot-token: {VALID_BOT_TOKEN}" + ).replace("main-guild-id: 0", "main-guild-id: 1234567890123456789") + + @staticmethod + def test_the_example_configuration_is_valid(write_config: "ConfigWriter") -> None: + """ + Test that the shipped example validates once its required values are filled in. + + Somebody setting TeX-Bot up copies this file, supplies their token & guild ID, + and starts it. Every other setting within it must therefore already be valid as + written, including each one that is only there to show what it would look like. + """ + document: SettingsDocument = SettingsDocument.load( + write_config( + TestExampleConfiguration._example_config_with_required_settings_filled_in() + ) + ) + + SettingsSchema.model_validate(document.raw) + + @staticmethod + def test_the_example_configuration_leaves_optional_settings_absent( + write_config: "ConfigWriter", + ) -> None: + """ + Test that the example supplies no placeholder in place of an optional setting. + + A setting that is present must hold a valid value, so a placeholder such as `""` + is rejected rather than treated as the setting having been left unset. + """ + settings: SettingsSchema = SettingsSchema.model_validate( + SettingsDocument.load( + write_config( + TestExampleConfiguration._example_config_with_required_settings_filled_in() + ) + ).raw + ) + + assert settings.community_group.full_name is None + assert settings.community_group.links.purchase_membership is None + assert settings.community_group.msl.organisation_id is None + assert settings.community_group.msl.auth_cookie is None + + def test_temporary_file_is_written_alongside_its_destination(config_file: "Path") -> None: """ Test that the temporary file used while writing is created beside the destination. diff --git a/tests/config/test_logging.py b/tests/config/test_logging.py index 29c2bf84f..f8fb6c61d 100644 --- a/tests/config/test_logging.py +++ b/tests/config/test_logging.py @@ -98,6 +98,18 @@ def test_a_console_handler_is_attached() -> None: assert len(_handlers_of_type(LOGGER_NAME, logging.StreamHandler)) == 1 + @staticmethod + def test_console_logs_are_not_passed_to_the_root_logger() -> None: + """ + Test that TeX-Bot's own logs stop at its own logger. + + Propagating them would emit each record a second time through whichever handler + the root logger happens to hold. + """ + apply_logging_settings(_logging_settings()) + + assert logging.getLogger(LOGGER_NAME).propagate is False + @staticmethod def test_applying_settings_repeatedly_does_not_accumulate_handlers() -> None: """ @@ -185,6 +197,53 @@ def test_a_file_handler_is_attached_when_enabled(tmp_path: "Path") -> None: assert len(FILE_HANDLERS) == 1 assert logging.getLogger(DISCORD_LOGGER_NAME).level == logging.DEBUG + @staticmethod + def test_discord_api_logs_are_captured_only_while_they_are_recorded( + tmp_path: "Path", + ) -> None: + """ + Test that Discord API logs are released back once they stop being recorded. + + While they are being recorded they are held at their own logger, so that they do + not also appear amongst TeX-Bot's own console output. + """ + apply_logging_settings( + _logging_settings( + **{ + "discord-api": { + "enabled": True, + "file-name": str(tmp_path / "discord.log"), + } + } + ) + ) + assert logging.getLogger(DISCORD_LOGGER_NAME).propagate is False + + apply_logging_settings(_logging_settings(**{"discord-api": {"enabled": False}})) + + assert logging.getLogger(DISCORD_LOGGER_NAME).propagate is True + + @staticmethod + def test_the_log_file_is_released_when_recording_stops(tmp_path: "Path") -> None: + """ + Test that the Discord API log file is closed once it stops being written to. + + Leaving the handler's file open would hold a lock upon it for as long as TeX-Bot + continued to run, so each reload would strand another one. + """ + LOG_FILE_PATH: Final[Path] = tmp_path / "discord.log" + + apply_logging_settings( + _logging_settings( + **{"discord-api": {"enabled": True, "file-name": str(LOG_FILE_PATH)}} + ) + ) + apply_logging_settings(_logging_settings(**{"discord-api": {"enabled": False}})) + + LOG_FILE_PATH.unlink() + + assert not LOG_FILE_PATH.exists() + @staticmethod def test_disabling_afterwards_detaches_the_handler(tmp_path: "Path") -> None: """Test that disabling Discord API logging stops recording it.""" diff --git a/tests/config/test_messages.py b/tests/config/test_messages.py index f2b80de77..acc04cd76 100644 --- a/tests/config/test_messages.py +++ b/tests/config/test_messages.py @@ -148,6 +148,33 @@ def test_a_string_is_not_accepted_as_a_set_of_messages( with pytest.raises(MessagesJSONFileValueError): MessagesAccessor().reload() + @staticmethod + def test_an_object_is_not_accepted_as_a_set_of_messages( + write_messages: "MessagesWriter", + ) -> None: + """ + Test that a JSON object is not treated as a set of messages. + + An object is iterable too, so without an explicit check it would be accepted and + silently reduced to its own keys, discarding every message it holds. + """ + write_messages({**VALID_MESSAGES, "welcome_messages": {"Welcome!": "Hello there!"}}) + + with pytest.raises(MessagesJSONFileValueError): + MessagesAccessor().reload() + + @staticmethod + def test_messages_that_are_not_strings_are_read_as_text( + write_messages: "MessagesWriter", + ) -> None: + """Test that a message written as a number is read as the text of that number.""" + write_messages({**VALID_MESSAGES, "roles_messages": [42, "Get your roles here."]}) + messages: MessagesAccessor = MessagesAccessor() + + messages.reload() + + assert messages.roles_messages == frozenset({"42", "Get your roles here."}) + class TestFailedReloads: """Test case for what happens when messages cannot be reloaded.""" diff --git a/tests/config/test_schema.py b/tests/config/test_schema.py index 34ca781b6..b8233f1e2 100644 --- a/tests/config/test_schema.py +++ b/tests/config/test_schema.py @@ -130,7 +130,7 @@ def test_valid_durations_are_parsed( ( "30m1h", # NOTE: Units given smallest-first "1h 30m", # NOTE: Whitespace between units - "", # NOTE: A zero-length duration would cause a task to spin + "", # NOTE: An empty string is not a duration "PT1H30M", # NOTE: An ISO-8601 duration "01:30:00", "5", # NOTE: No unit given @@ -145,6 +145,74 @@ def test_invalid_durations_are_rejected(raw_duration: str) -> None: _config(commands={"strike": {"timeout-duration": raw_duration}}) ) + @staticmethod + def test_an_already_parsed_duration_is_kept() -> None: + """ + Test that a value that is already a length of time is accepted unchanged. + + Each default value is re-validated whenever the model holding it is rebuilt, so + rejecting anything that is not a string would reject the schema's own defaults. + """ + settings: SettingsSchema = SettingsSchema.model_validate( + _config(commands={"strike": {"timeout-duration": datetime.timedelta(hours=3)}}) + ) + + assert settings.commands.strike.timeout_duration == datetime.timedelta(hours=3) + + +class TestZeroLengthDurations: + """Test case for durations of no length at all.""" + + @staticmethod + @pytest.mark.parametrize("raw_duration", ("0s", "0m", "0d0h0m0s", "0.0s")) + def test_a_zero_length_interval_is_rejected(raw_duration: str) -> None: + """ + Test that an interval of no length at all is rejected. + + Each interval becomes the loop period of a recurring task, so a zero-length one + would cause that task to run continuously without ever pausing. Rejecting only + the empty string is not enough, because `0s` is written deliberately. + """ + with pytest.raises(ValidationError, match="longer than zero"): + SettingsSchema.model_validate( + _config(reminders={"send-get-roles-reminders": {"interval": raw_duration}}) + ) + + @staticmethod + @pytest.mark.parametrize( + "raw_settings", + ( + { + "community-group": { + "links": {}, + "msl": {"auto-cookie-checking": {"interval": "0s"}}, + } + }, + {"reminders": {"send-introduction-reminders": {"interval": "0s"}}}, + {"reminders": {"send-get-roles-reminders": {"interval": "0s"}}}, + ), + ) + def test_every_task_interval_rejects_a_zero_length_duration( + raw_settings: "Mapping[str, object]", + ) -> None: + """Test that no recurring task can be configured to loop without pausing.""" + with pytest.raises(ValidationError, match="longer than zero"): + SettingsSchema.model_validate({**REQUIRED_SETTINGS, **raw_settings}) + + @staticmethod + def test_a_zero_length_delay_is_accepted() -> None: + """ + Test that waiting no time at all before sending a reminder is allowed. + + Unlike an interval, a delay of zero is a meaningful instruction: send the + reminder as soon as the member becomes eligible for it. + """ + settings: SettingsSchema = SettingsSchema.model_validate( + _config(reminders={"send-get-roles-reminders": {"delay": "0s"}}) + ) + + assert settings.reminders.send_get_roles_reminders.delay == datetime.timedelta(0) + class TestValueConstraints: """Test case for the constraints applied to individual settings values.""" @@ -197,6 +265,110 @@ def test_invalid_bot_token_is_rejected(bot_token: str) -> None: } ) + @staticmethod + @pytest.mark.parametrize("separator", ("--", "__")) + def test_bot_token_containing_repeated_punctuation_is_rejected(separator: str) -> None: + """ + Test that a token holding two consecutive hyphens or underscores is rejected. + + Discord never issues such a token, so one appearing here almost always means a + placeholder has been left in place of a real token. + """ + REPEATED_PUNCTUATION_TOKEN: Final[str] = ( + f"{VALID_BOT_TOKEN[:-4]}{separator}{VALID_BOT_TOKEN[-2:]}" + ) + + with pytest.raises(ValidationError, match="Discord bot token"): + SettingsSchema.model_validate( + { + **REQUIRED_SETTINGS, + "discord": { + "bot-token": REPEATED_PUNCTUATION_TOKEN, + "main-guild-id": VALID_MAIN_GUILD_ID, + }, + } + ) + + @staticmethod + def test_bot_token_containing_differing_punctuation_is_accepted() -> None: + """Test that a hyphen adjacent to an underscore is not treated as a repetition.""" + MIXED_PUNCTUATION_TOKEN: Final[str] = f"{VALID_BOT_TOKEN[:-4]}-_{VALID_BOT_TOKEN[-2:]}" + + settings: SettingsSchema = SettingsSchema.model_validate( + { + **REQUIRED_SETTINGS, + "discord": { + "bot-token": MIXED_PUNCTUATION_TOKEN, + "main-guild-id": VALID_MAIN_GUILD_ID, + }, + } + ) + + assert settings.discord.bot_token.get_secret_value() == MIXED_PUNCTUATION_TOKEN + + @staticmethod + @pytest.mark.parametrize("lookback_days", (5, 1826)) + def test_statistics_lookback_accepts_its_range(lookback_days: int) -> None: + """Test that a lookback within five days to five years inclusive is accepted.""" + settings: SettingsSchema = SettingsSchema.model_validate( + _config(commands={"stats": {"lookback-days": lookback_days}}) + ) + + assert settings.commands.stats.lookback_days == lookback_days + + @staticmethod + @pytest.mark.parametrize("lookback_days", (4, 1827, 0, -1)) + def test_statistics_lookback_rejects_values_outside_its_range(lookback_days: int) -> None: + """Test that a lookback outside five days to five years inclusive is rejected.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config(commands={"stats": {"lookback-days": lookback_days}}) + ) + + @staticmethod + @pytest.mark.parametrize("organisation_id", ("123", "123456", "12a4", "", "1234 ")) + def test_invalid_msl_organisation_id_is_rejected(organisation_id: str) -> None: + """Test that an organisation ID that is not four or five digits is rejected.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config( + **{ + "community-group": { + "links": {}, + "msl": {"organisation-id": organisation_id}, + } + } + ) + ) + + @staticmethod + @pytest.mark.parametrize( + ("setting_name", "value_with_trailing_newline"), + (("organisation-id", "1234\n"), ("auth-cookie", f"{'a' * 512}\n")), + ) + def test_a_trailing_newline_does_not_satisfy_a_pattern( + setting_name: str, value_with_trailing_newline: str + ) -> None: + r""" + Test that a value is not accepted merely because its first line would be. + + Every pattern is anchored with `\Z` rather than `$`, so that a trailing newline + cannot smuggle an otherwise-rejected value through. This is why the schema uses + Python's regular-expression engine, rather than the Rust engine Pydantic + defaults to, which does not support that anchor. + """ + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config( + **{ + "community-group": { + "links": {}, + "msl": {setting_name: value_with_trailing_newline}, + } + } + ) + ) + @staticmethod def test_non_discord_webhook_url_is_rejected() -> None: """Test that a URL that is not a Discord webhook is rejected.""" @@ -217,6 +389,23 @@ def test_discord_webhook_url_is_accepted() -> None: "https://discord.com/api/webhooks/" ) + @staticmethod + def test_a_sequence_of_distinct_values_is_accepted() -> None: + """Test that a list of role names holding no repetitions is kept as written.""" + settings: SettingsSchema = SettingsSchema.model_validate( + _config( + **{ + "community-group": { + "links": {}, + "msl": {}, + "membership-dependent-roles": ["Member", "Committee"], + } + } + ) + ) + + assert settings.community_group.membership_dependent_roles == ("Member", "Committee") + @staticmethod def test_duplicate_values_within_a_unique_sequence_are_rejected() -> None: """Test that repeating a value within a set of role names is rejected.""" @@ -260,9 +449,14 @@ def test_unknown_log_level_is_rejected() -> None: (True, "once"), ("true", "once"), ("yes", "once"), + ("on", "once"), + ("1", "once"), + (" Once ", "once"), (False, False), ("false", False), ("no", False), + ("off", False), + ("0", False), ), ) def test_send_introduction_reminders_flag_is_normalised( @@ -275,6 +469,15 @@ def test_send_introduction_reminders_flag_is_normalised( assert settings.reminders.send_introduction_reminders.enabled == expected_flag + @staticmethod + @pytest.mark.parametrize("raw_flag", ("maybe", "sometimes", "ONCE-ish")) + def test_an_unrecognised_reminders_flag_string_is_rejected(raw_flag: str) -> None: + """Test that a string outside the recognised options is not quietly accepted.""" + with pytest.raises(ValidationError): + SettingsSchema.model_validate( + _config(reminders={"send-introduction-reminders": {"enabled": raw_flag}}) + ) + @staticmethod def test_lookback_days_is_exposed_as_a_period() -> None: """Test that the statistics lookback is usable as a length of time.""" @@ -452,9 +655,15 @@ def test_a_log_level_that_is_not_a_string_is_rejected(raw_log_level: object) -> ) @staticmethod - @pytest.mark.parametrize("raw_flag", (42, None, ["once"])) + @pytest.mark.parametrize("raw_flag", (42, None, ["once"], 0, 1)) def test_an_unrecognised_reminders_flag_is_rejected(raw_flag: object) -> None: - """Test that a send-introduction-reminders value outside its options is rejected.""" + """ + Test that a send-introduction-reminders value outside its options is rejected. + + `0` is included because Pydantic would otherwise accept it as `False` while + rejecting the `1` its author would pair it with, silently disabling introduction + reminders for anyone who wrote them as numbers. + """ with pytest.raises(ValidationError): SettingsSchema.model_validate( _config(reminders={"send-introduction-reminders": {"enabled": raw_flag}}) diff --git a/tex-bot-deployment.example.yaml b/tex-bot-deployment.example.yaml index ccc113ce6..05fd73349 100644 --- a/tex-bot-deployment.example.yaml +++ b/tex-bot-deployment.example.yaml @@ -9,6 +9,9 @@ # # Durations are written largest-unit-first, in the format # `dhms`, so `1h30m` and `2d` are both valid. +# Every part must carry its unit, so a bare `24` is rejected rather than being read as +# 24 seconds. The `interval` of a recurring task must be longer than zero, because a +# task set to repeat every `0s` would run continuously without ever pausing. # # Run `/config reload` after editing this file to apply your changes. # Most settings take effect straight away, because they are read at the moment they @@ -33,24 +36,31 @@ discord: community-group: # Optional. Falls back to the name of your Discord guild. - full-name: "" + # full-name: Computer Science Society # Optional. Falls back to being derived from the full name. - short-name: "" + # short-name: CSS # Optional. Roles that should only be held by members of your community group. membership-dependent-roles: [] - links: - purchase-membership: "" - membership-perks: "" - moderation-policy: "" - # Optional. Used in place of an invite link generated by TeX-Bot. - custom-discord-invite-link: "" + # Every link is optional. To set any of them, replace the `{}` below with the + # commented-out block beneath it, keeping only the links you have. + # + # An empty value is not the same as an absent one: every setting that is present must + # hold a valid value, so leave a setting out entirely rather than setting it to "". + links: {} + # links: + # purchase-membership: https://example.com/join + # membership-perks: https://example.com/perks + # moderation-policy: https://example.com/moderation-policy + # # Used in place of an invite link generated by TeX-Bot. + # custom-discord-invite-link: https://discord.gg/example msl: - organisation-id: "" - # Your members-list authentication session cookie. On the UoB Guild of + # Optional. Your community group's organisation ID on your MSL website. + # organisation-id: "1234" + # Optional. Your members-list authentication session cookie. On the UoB Guild of # Students website this is the cookie named `.ASPXAUTH`. - auth-cookie: "" + # auth-cookie: your-authentication-cookie-value auto-cookie-checking: enabled: false interval: 10m From 5d065d390641e2981ccebc73d7acb6b9387664df Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:05:49 +0100 Subject: [PATCH 23/33] Add "/config get", "/config set" & "/config unset" commands Committee members can now read & change individual settings from within Discord, without editing the deployment configuration file by hand. Every change is applied to a copy of the file & validated before anything is written, so a value that would be rejected leaves both the file & the running configuration exactly as they were. The file is read again before each change rather than the copy held in memory being rewritten, so an edit made by hand in the meantime is kept rather than silently reverted. The comments & formatting of the file survive being rewritten, as before. Values are read exactly as the same text would be if it had been written into the file directly, with two exceptions worth naming: * A setting declared to hold text stays text, so an organisation ID of `1234` is not written as a number & then rejected for being one. This is derived from the schema, so it cannot drift from what is declared. * Text holding a colon, or beginning with a `#`, is kept as the text it plainly is rather than being read as a mapping or as a comment. Removing a setting needs no knowledge of which settings are required: the configuration that removing it would produce simply does not validate. Also fixes a crash this uncovered: reporting a validation failure against a setting that had just been added to the document raised `TypeError`, & then `KeyError`, from `line_number_of()`, because such a setting has no line within the file it was parsed from. Every rejected new setting would have surfaced as an unhandled exception rather than as an explanation. Autocomplete matches anywhere within a setting's name, rather than only at its beginning, because each name is prefixed with the section holding it. --- cogs/config.py | 302 +++++++++++++++++++- config/__init__.py | 58 ++++ config/_document.py | 110 +++++++- config/_editor.py | 222 +++++++++++++++ config/_schema.py | 23 ++ tests/config/test_document.py | 69 +++++ tests/config/test_editor.py | 473 ++++++++++++++++++++++++++++++++ tex-bot-deployment.example.yaml | 5 + 8 files changed, 1255 insertions(+), 7 deletions(-) create mode 100644 config/_editor.py create mode 100644 tests/config/test_editor.py diff --git a/cogs/config.py b/cogs/config.py index e87ea7a31..68b4df329 100644 --- a/cogs/config.py +++ b/cogs/config.py @@ -9,7 +9,9 @@ from config import ( InvalidSettingsFileError, SettingsFileNotFoundError, + SettingsNotLoadedError, SettingsValidationError, + UnknownSettingError, ) from utils import CommandChecks, TeXBotBaseCog @@ -19,7 +21,8 @@ from logging import Logger from typing import Final - from utils import TeXBotApplicationContext + from config import ConfigReloadResult, ConfigSettingMetadata + from utils import TeXBotApplicationContext, TeXBotAutocompleteContext __all__: "Sequence[str]" = ("ConfigCommandsCog",) @@ -29,6 +32,9 @@ MAXIMUM_LISTED_SETTINGS: "Final[int]" = 20 +# NOTE: The greatest number of suggestions that Discord will display at once. +MAXIMUM_AUTOCOMPLETE_SUGGESTIONS: "Final[int]" = 25 + def _format_settings_list(settings_names: "Iterable[str]") -> str: """Format the given settings key paths into a bulleted list, truncated if very long.""" @@ -47,6 +53,18 @@ def _format_settings_list(settings_names: "Iterable[str]") -> str: return formatted_settings_list +def _format_restart_required_warning(restart_required_settings: "AbstractSet[str]") -> str: + """Format the warning that a change cannot take effect until TeX-Bot is restarted.""" + if not restart_required_settings: + return "" + + return ( + "\n\n:warning: **TeX-Bot must be restarted " + "before the following take effect:**\n" + f"{_format_settings_list(restart_required_settings)}" + ) + + class ConfigCommandsCog(TeXBotBaseCog): """Cog class that defines the "/config" command group & its call-back methods.""" @@ -55,6 +73,32 @@ class ConfigCommandsCog(TeXBotBaseCog): description="View & change TeX-Bot's configuration.", ) + @staticmethod + async def autocomplete_get_settings_names( + ctx: "TeXBotAutocompleteContext", + ) -> "AbstractSet[discord.OptionChoice] | AbstractSet[str]": + """ + Autocomplete callable that generates the set of configurable settings names. + + Every setting whose name contains what has been typed so far is suggested, + rather than only those beginning with it, because each name is prefixed with the + section holding it & so is rarely typed from its beginning. + """ + TYPED_VALUE: Final[str] = str(ctx.value or "").strip().lower() + + MATCHING_SETTINGS_NAMES: Final[Sequence[str]] = [ + setting_name + for setting_name in config.documented_setting_names() + if TYPED_VALUE in setting_name.lower() + ] + + return { + discord.OptionChoice(name=setting_name, value=setting_name) + # NOTE: Sliced because Discord refuses a response holding more suggestions + # than it is willing to display. + for setting_name in MATCHING_SETTINGS_NAMES[:MAXIMUM_AUTOCOMPLETE_SUGGESTIONS] + } + @config.command( name="reload", description="Reload the configuration file, applying any changes made to it.", @@ -121,10 +165,260 @@ async def reload(self, ctx: "TeXBotApplicationContext") -> None: if restart_required_settings: response_message += ( - "\n\n:warning: **TeX-Bot must be restarted " - "before the following take effect:**\n" - f"{_format_settings_list(restart_required_settings)}\n" + f"{_format_restart_required_warning(restart_required_settings)}\n" "Every other change above has already been applied." ) await ctx.respond(response_message, ephemeral=True) + + @config.command( + name="get", + description="Show the current value of a single configuration setting.", + ) + @discord.option( + name="setting", + description="The name of the setting to show.", + input_type=str, + autocomplete=autocomplete_get_settings_names, + required=True, + parameter_name="setting_name", + ) + @CommandChecks.check_interaction_user_has_committee_role + @CommandChecks.check_interaction_user_in_main_guild + async def get(self, ctx: "TeXBotApplicationContext", setting_name: str) -> None: + """ + Definition & callback response of the "config get" command. + + Shows what a single setting is currently set to, along with what it controls. + """ + await ctx.defer(ephemeral=True) + + setting_metadata: ConfigSettingMetadata + try: + setting_metadata = config.setting_metadata(setting_name) + CURRENT_VALUE: Final[object] = config.settings.as_flat_mapping()[setting_name] + except UnknownSettingError as unknown_setting_error: + await self._respond_with_unknown_setting(ctx, unknown_setting_error) + return + except SettingsNotLoadedError: + await ctx.respond( + ":x: No configuration has been loaded, so no setting can be shown.", + ephemeral=True, + ) + return + + IS_SET_WITHIN_FILE: Final[bool] = config.settings.document.contains( + setting_name.split(config.SETTING_NAME_SEPARATOR) + ) + + response_message: str = ( + f"**`{setting_name}`**\n" + f"{config.format_setting_value(CURRENT_VALUE, secret=setting_metadata.secret)}" + ) + + if not IS_SET_WITHIN_FILE: + response_message += " _(default; not set within the configuration file)_" + + if setting_metadata.description: + response_message += f"\n\n{setting_metadata.description}" + + if setting_metadata.requires_restart: + response_message += ( + "\n\n:warning: Changing this setting requires TeX-Bot to be restarted." + ) + + await ctx.respond(response_message, ephemeral=True) + + @config.command( + name="set", + description="Change a single configuration setting, then apply the change.", + ) + @discord.option( + name="setting", + description="The name of the setting to change.", + input_type=str, + autocomplete=autocomplete_get_settings_names, + required=True, + parameter_name="setting_name", + ) + @discord.option( + name="value", + description="The new value, written exactly as it would be within the file.", + input_type=str, + required=True, + min_length=1, + max_length=1000, + parameter_name="raw_value", + ) + @CommandChecks.check_interaction_user_has_committee_role + @CommandChecks.check_interaction_user_in_main_guild + async def set( + self, ctx: "TeXBotApplicationContext", setting_name: str, raw_value: str + ) -> None: + """ + Definition & callback response of the "config set" command. + + Writes a single setting into the configuration file & applies it. The new value + is validated first, so a value that would be rejected changes nothing at all. + """ + await ctx.defer(ephemeral=True) + + reload_result: ConfigReloadResult + try: + setting_metadata: ConfigSettingMetadata = config.setting_metadata(setting_name) + reload_result = config.set_setting(setting_name, raw_value) + except UnknownSettingError as unknown_setting_error: + await self._respond_with_unknown_setting(ctx, unknown_setting_error) + return + except SettingsValidationError as validation_error: + await self._respond_with_rejected_change(ctx, setting_name, validation_error) + return + except ( + SettingsFileNotFoundError, + InvalidSettingsFileError, + OSError, + ) as configuration_error: + await self._respond_with_unusable_file(ctx, configuration_error) + return + + # NOTE: The value itself is deliberately not repeated back, so that setting a + # secret cannot leave a copy of it within a Discord channel. + FORMATTED_NEW_VALUE: Final[str] = config.format_setting_value( + config.settings.as_flat_mapping()[setting_name], + secret=setting_metadata.secret, + ) + + logger.info("Configuration setting %r changed via the /config command.", setting_name) + + await ctx.respond( + ( + f":white_check_mark: Set **`{setting_name}`** to " + f"{FORMATTED_NEW_VALUE}." + f"{_format_restart_required_warning(reload_result.restart_required_settings)}" + ), + ephemeral=True, + ) + + @config.command( + name="unset", + description="Return a single configuration setting to its default value.", + ) + @discord.option( + name="setting", + description="The name of the setting to return to its default value.", + input_type=str, + autocomplete=autocomplete_get_settings_names, + required=True, + parameter_name="setting_name", + ) + @CommandChecks.check_interaction_user_has_committee_role + @CommandChecks.check_interaction_user_in_main_guild + async def unset(self, ctx: "TeXBotApplicationContext", setting_name: str) -> None: + """ + Definition & callback response of the "config unset" command. + + Removes a single setting from the configuration file, returning it to its + default value. A setting that is required cannot be removed, because the + configuration that removing it would produce is not one TeX-Bot could load. + """ + await ctx.defer(ephemeral=True) + + reload_result: ConfigReloadResult | None + try: + setting_metadata: ConfigSettingMetadata = config.setting_metadata(setting_name) + reload_result = config.unset_setting(setting_name) + except UnknownSettingError as unknown_setting_error: + await self._respond_with_unknown_setting(ctx, unknown_setting_error) + return + except SettingsValidationError as validation_error: + await ctx.respond( + ( + f":x: **`{setting_name}`** was **not** removed, because the " + f"configuration would no longer be valid without it. " + f"Nothing has been changed.\n" + f"```\n{validation_error}\n```" + ), + ephemeral=True, + ) + return + except ( + SettingsFileNotFoundError, + InvalidSettingsFileError, + OSError, + ) as configuration_error: + await self._respond_with_unusable_file(ctx, configuration_error) + return + + if reload_result is None: + await ctx.respond( + ( + f":information_source: **`{setting_name}`** is not set within the " + f"configuration file, so it is already using its default value." + ), + ephemeral=True, + ) + return + + logger.info("Configuration setting %r removed via the /config command.", setting_name) + + await ctx.respond( + ( + f":white_check_mark: Removed **`{setting_name}`**, " + f"which has returned to its default value of " + f"{ + config.format_setting_value( + config.settings.as_flat_mapping()[setting_name], + secret=setting_metadata.secret, + ) + }." + f"{_format_restart_required_warning(reload_result.restart_required_settings)}" + ), + ephemeral=True, + ) + + @staticmethod + async def _respond_with_unknown_setting( + ctx: "TeXBotApplicationContext", unknown_setting_error: UnknownSettingError + ) -> None: + """Explain that the named setting is not one that TeX-Bot has.""" + await ctx.respond( + ( + f":x: {unknown_setting_error}\n" + "Choose a setting from the suggestions shown as you type." + ), + ephemeral=True, + ) + + @staticmethod + async def _respond_with_rejected_change( + ctx: "TeXBotApplicationContext", + setting_name: str, + validation_error: SettingsValidationError, + ) -> None: + """Explain that the given value was refused, & that nothing has been changed.""" + logger.warning("Change to %r rejected:\n%s", setting_name, validation_error) + + await ctx.respond( + ( + f":x: **`{setting_name}`** was **not** changed, because that value is " + f"not valid. Nothing has been changed.\n" + f"```\n{validation_error}\n```" + ), + ephemeral=True, + ) + + @staticmethod + async def _respond_with_unusable_file( + ctx: "TeXBotApplicationContext", configuration_error: Exception + ) -> None: + """Explain that the configuration file itself could not be read or written.""" + logger.warning("Configuration file could not be used: %s", configuration_error) + + await ctx.respond( + ( + ":x: The configuration file could not be read or written, " + "so nothing has been changed.\n" + f"```\n{configuration_error}\n```" + ), + ephemeral=True, + ) diff --git a/config/__init__.py b/config/__init__.py index b8383ff70..27de2e790 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -18,6 +18,16 @@ SettingsFileNotFoundError, get_settings_file_path, ) +from ._editor import ( + SETTING_NAME_SEPARATOR, + UnknownSettingError, + documented_setting_names, + format_setting_value, + parse_setting_value, + setting_metadata, + validated_document_with_setting_removed, + validated_document_with_setting_set, +) from ._logging import apply_logging_settings from ._messages import MessagesAccessor from ._schema import ConfigSettingMetadata, get_settings_metadata @@ -30,6 +40,7 @@ __all__: "Sequence[str]" = ( + "SETTING_NAME_SEPARATOR", "ConfigReloadResult", "ConfigSettingMetadata", "InvalidSettingsFileError", @@ -37,12 +48,18 @@ "SettingsFileNotFoundError", "SettingsNotLoadedError", "SettingsValidationError", + "UnknownSettingError", + "documented_setting_names", + "format_setting_value", "get_settings_file_path", "get_settings_metadata", "messages", "reload_settings", "run_setup", + "set_setting", + "setting_metadata", "settings", + "unset_setting", ) @@ -90,6 +107,47 @@ def reload_settings() -> ConfigReloadResult: ) +def set_setting(setting_name: str, raw_value: str) -> ConfigReloadResult: + """ + Change a single setting within the configuration file, then apply the change. + + The new value is validated before the file is written, so a value that would be + rejected leaves both the file & the running configuration exactly as they were. + + Returns the settings that changed as a result, along with the subset of those that + cannot take effect until TeX-Bot is restarted. + """ + UPDATED_DOCUMENT: Final[SettingsDocument] = validated_document_with_setting_set( + setting_name, parse_setting_value(setting_name, raw_value) + ) + + UPDATED_DOCUMENT.write() + + return reload_settings() + + +def unset_setting(setting_name: str) -> ConfigReloadResult | None: + """ + Remove a single setting from the configuration file, then apply its removal. + + The setting returns to its default value. `None` is returned where the setting was + not written within the file to begin with, so nothing needed to be removed. + + Removing a setting that is required leaves the file untouched, because the + configuration that removing it would produce is not valid. + """ + UPDATED_DOCUMENT: Final[SettingsDocument | None] = validated_document_with_setting_removed( + setting_name + ) + + if UPDATED_DOCUMENT is None: + return None + + UPDATED_DOCUMENT.write() + + return reload_settings() + + def run_setup() -> None: """Execute the setup functions required before TeX-Bot can be run.""" reload_settings() diff --git a/config/_document.py b/config/_document.py index 2ad612fac..ec13bf5b5 100644 --- a/config/_document.py +++ b/config/_document.py @@ -9,6 +9,7 @@ the shape & meaning of the configuration is declared solely within `config._schema`. """ +import copy import io import logging import os @@ -175,6 +176,85 @@ def raw(self) -> "CommentedMap": """ return self._raw + def copy(self) -> "Self": + """ + Return an independent copy of this document, retaining its comments & formatting. + + Used to apply a change to a copy & validate the result, so that a change which + turns out to be invalid never reaches the document that is currently loaded. + """ + return type(self)(file_path=self._file_path, raw=copy.deepcopy(self._raw)) + + def contains(self, key_path: "Sequence[str]") -> bool: + """Whether the given sequence of keys is written within this document.""" + node: object = self._raw + + key: str + for key in key_path: + if not isinstance(node, dict) or key not in node: + return False + + node = node[key] + + return True + + def set_value(self, key_path: "Sequence[str]", value: object) -> None: + """ + Write the given value at the given sequence of keys, creating any absent sections. + + Nothing is written to disk until `write()` is called. + """ + if not key_path: + EMPTY_KEY_PATH_MESSAGE: str = "A setting to change must be named." + raise ValueError(EMPTY_KEY_PATH_MESSAGE) + + node: CommentedMap = self._raw + + key: str + for key in key_path[:-1]: + if key not in node: + node[key] = CommentedMap() + + child_node: object = node[key] + if not isinstance(child_node, CommentedMap): + NOT_A_SECTION_MESSAGE: str = ( + f"{self._file_path.name} cannot hold the setting " + f"{':'.join(key_path)!r}, because {key!r} is not a section." + ) + raise InvalidSettingsFileError(NOT_A_SECTION_MESSAGE) + + node = child_node + + node[key_path[-1]] = value + + def unset_value(self, key_path: "Sequence[str]") -> bool: + """ + Remove the given sequence of keys, reporting whether it was written at all. + + Any section left empty is kept, rather than being removed alongside the setting, + because a section may be required to be present even when it holds nothing. + + Nothing is written to disk until `write()` is called. + """ + if not self.contains(key_path): + return False + + node: object = self._raw + + key: str + for key in key_path[:-1]: + if TYPE_CHECKING: + assert isinstance(node, dict) + + node = node[key] + + if TYPE_CHECKING: + assert isinstance(node, dict) + + del node[key_path[-1]] + + return True + def dump(self) -> str: """Serialise this document back into YAML, retaining comments & formatting.""" output_buffer: io.StringIO = io.StringIO() @@ -249,13 +329,37 @@ def line_number_of(self, key_path: "Sequence[str | int]") -> int | None: if TYPE_CHECKING: assert isinstance(node, dict) - # NOTE: `lc.key()` reports a zero-indexed line, whereas humans (& every editor) - # count the first line of a file as line 1. - deepest_known_line_number = node.lc.key(key)[0] + 1 # type: ignore[attr-defined] + LINE_NUMBER_OF_KEY: int | None = self._line_number_of_key(node, key) + if LINE_NUMBER_OF_KEY is None: + break + + deepest_known_line_number = LINE_NUMBER_OF_KEY node = node[key] return deepest_known_line_number + @staticmethod + def _line_number_of_key(node: "dict[object, object]", key: "str | int") -> int | None: + """ + Return the line that the given key of the given mapping was parsed from. + + `None` is returned for a key that was added to the document rather than parsed + from the file, because such a key has no line within the file to report. This + happens whenever the `/config` command writes a setting for the first time. + """ + key_position: object + try: + key_position = node.lc.key(key) # type: ignore[attr-defined] + except KeyError: + return None + + if not isinstance(key_position, tuple): + return None + + # NOTE: `lc.key()` reports a zero-indexed line, whereas humans (& every editor) + # count the first line of a file as line 1. + return int(key_position[0]) + 1 + def _format_single_error(self, key_path: "Sequence[str | int]", message: str) -> str: """Format a single validation failure, prefixed with the location that caused it.""" LINE_NUMBER: Final[int | None] = self.line_number_of(key_path) diff --git a/config/_editor.py b/config/_editor.py new file mode 100644 index 000000000..36ea13585 --- /dev/null +++ b/config/_editor.py @@ -0,0 +1,222 @@ +""" +Changing individual settings within the deployment configuration file. + +Every change is applied to a copy of the configuration document & validated before +anything is written, so that a mistaken value can never leave TeX-Bot holding a +configuration file it would refuse to load. + +This module holds no knowledge of Discord: it turns the text a committee member typed +into a value, decides whether that value is acceptable, & renders values back into +something readable. Doing so here (rather than within the `/config` command itself) +keeps all of it testable without a connection to Discord. +""" + +import datetime +from typing import TYPE_CHECKING + +from pydantic import SecretStr, ValidationError +from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError + +from ._accessor import SettingsValidationError +from ._document import SettingsDocument +from ._schema import SettingsSchema, get_settings_metadata + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from typing import Final + + from ._schema import ConfigSettingMetadata + + +__all__: "Sequence[str]" = ( + "SETTING_NAME_SEPARATOR", + "UnknownSettingError", + "documented_setting_names", + "format_setting_value", + "parse_setting_value", + "setting_metadata", + "validated_document_with_setting_removed", + "validated_document_with_setting_set", +) + + +SETTING_NAME_SEPARATOR: "Final[str]" = ":" + +REDACTED_VALUE_DISPLAY: "Final[str]" = "\\*\\*\\*\\*\\*\\* _(hidden)_" + +UNSET_VALUE_DISPLAY: "Final[str]" = "_(not set)_" + +# NOTE: Anything YAML would read back as a value other than a string, so that a value +# given as one of these words is not written into the file as bare text. +_NULL_LITERALS: "Final[frozenset[str]]" = frozenset({"null", "~"}) + + +class UnknownSettingError(Exception): + """Exception class to raise when a setting that the schema does not declare is named.""" + + def __init__(self, setting_name: str) -> None: + """Initialise a new UnknownSettingError for the given setting name.""" + self.setting_name: str = setting_name + + super().__init__(f"No configuration setting is named {setting_name!r}.") + + +def documented_setting_names() -> "Sequence[str]": + """Return the name of every setting that can be viewed or changed, in order.""" + return sorted(get_settings_metadata()) + + +def _key_path_of(setting_name: str) -> "Sequence[str]": + """Convert a colon-separated setting name into the sequence of keys it refers to.""" + if setting_name not in get_settings_metadata(): + raise UnknownSettingError(setting_name) + + return setting_name.split(SETTING_NAME_SEPARATOR) + + +def _read_as_yaml(raw_value: str) -> object: + """Read the given text as though it had been written into the configuration file.""" + try: + # NOTE: Deliberately the safe parser: the value comes from whoever ran the + # command, & the round-trip parser used elsewhere can construct arbitrary + # Python objects from a value that names them. + return YAML(typ="safe", pure=True).load(raw_value) + except YAMLError: + return None + + +def parse_setting_value(setting_name: str, raw_value: str) -> object: + """ + Convert the text a committee member typed into the value to write into the file. + + The text is read exactly as it would be if it had been written into the + configuration file by hand, so that `true` becomes a boolean, `30` becomes a number, + and `1h30m` remains the text of a duration. + + A setting declared to hold text is kept as text, so that a value which merely looks + like a number (an organisation ID of `1234`, for example) is not written as one & + then rejected for being the wrong type. Quoting such a value is still respected, + so that `"1234"` means the same as `1234` rather than including the quotes. + """ + PARSED_VALUE: Final[object] = _read_as_yaml(raw_value) + + if setting_metadata(setting_name).holds_text: + return PARSED_VALUE if isinstance(PARSED_VALUE, str) else raw_value + + if isinstance(PARSED_VALUE, dict): + # NOTE: A value holding a colon parses as a mapping, even though somebody typing + # `Computer Science: Society` into a single setting meant it as plain text. + return raw_value + + if PARSED_VALUE is None and raw_value.strip().lower() not in _NULL_LITERALS: + # NOTE: A value beginning with `#` parses as a comment, & so as nothing at all, + # as does a value that cannot be read as YAML at all. + return raw_value + + return PARSED_VALUE + + +def _format_duration(duration: datetime.timedelta) -> str: + """Render a length of time in the same format the configuration file holds it in.""" + remaining_seconds: float = duration.total_seconds() + if remaining_seconds <= 0: + return "0s" + + formatted_duration: str = "" + + unit_name: str + seconds_within_unit: int + for unit_name, seconds_within_unit in (("d", 86400), ("h", 3600), ("m", 60)): + unit_count: int = int(remaining_seconds // seconds_within_unit) + if unit_count: + formatted_duration += f"{unit_count}{unit_name}" + remaining_seconds -= unit_count * seconds_within_unit + + if remaining_seconds: + formatted_duration += f"{remaining_seconds:g}s" + + return formatted_duration + + +def format_setting_value(value: object, *, secret: bool) -> str: + """ + Render the value of a single setting into something readable. + + A secret value is never rendered, so that displaying a setting cannot reveal a + credential to whoever can see the response. + """ + if value is None: + return UNSET_VALUE_DISPLAY + + if secret or isinstance(value, SecretStr): + return REDACTED_VALUE_DISPLAY + + if isinstance(value, datetime.timedelta): + return f"`{_format_duration(value)}`" + + if isinstance(value, tuple): + return ", ".join(f"`{single_value}`" for single_value in value) or UNSET_VALUE_DISPLAY + + if isinstance(value, bool): + return f"`{str(value).lower()}`" + + return f"`{value}`" + + +def _validated(document: SettingsDocument) -> SettingsDocument: + """Return the given document, having checked that it holds a valid configuration.""" + validation_error: ValidationError + try: + SettingsSchema.model_validate(document.raw) + except ValidationError as validation_error: + raise SettingsValidationError( + document.format_validation_error(validation_error) + ) from validation_error + + return document + + +def validated_document_with_setting_set(setting_name: str, value: object) -> SettingsDocument: + """ + Return the configuration file, holding the given value for the given setting. + + The file is read again rather than reusing the configuration already loaded, so that + any change made to it by hand since then is kept rather than being overwritten. + + Nothing is written to disk: the returned document must be written by its caller. + """ + KEY_PATH: Final[Sequence[str]] = _key_path_of(setting_name) + + document: SettingsDocument = SettingsDocument.load().copy() + document.set_value(KEY_PATH, value) + + return _validated(document) + + +def validated_document_with_setting_removed(setting_name: str) -> SettingsDocument | None: + """ + Return the configuration file, no longer holding the given setting. + + `None` is returned where the setting was not written within the file to begin with, + because removing it would leave the file exactly as it already is. + + Nothing is written to disk: the returned document must be written by its caller. + """ + KEY_PATH: Final[Sequence[str]] = _key_path_of(setting_name) + + document: SettingsDocument = SettingsDocument.load().copy() + if not document.unset_value(KEY_PATH): + return None + + return _validated(document) + + +def setting_metadata(setting_name: str) -> "ConfigSettingMetadata": + """Return the metadata describing the given setting.""" + SETTINGS_METADATA: Final[Mapping[str, ConfigSettingMetadata]] = get_settings_metadata() + + if setting_name not in SETTINGS_METADATA: + raise UnknownSettingError(setting_name) + + return SETTINGS_METADATA[setting_name] diff --git a/config/_schema.py b/config/_schema.py index 046018cf2..816f3ac52 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -30,6 +30,7 @@ if TYPE_CHECKING: from collections.abc import Iterator, Mapping, Sequence + from typing import Final from pydantic.fields import FieldInfo @@ -708,6 +709,27 @@ class ConfigSettingMetadata(NamedTuple): description: str | None requires_restart: bool secret: bool + holds_text: bool + + +def _holds_text(annotation: object) -> bool: + """ + Whether the given field annotation accepts text, & only text. + + Used to keep a value that merely looks like a number + (an organisation ID of `1234`, for example) as the text it is declared to be. + """ + if annotation is str: + return True + + ANNOTATION_ARGUMENTS: Final[tuple[object, ...]] = get_args(annotation) + + # NOTE: Every member of the annotation must be checked, rather than merely testing + # whether it contains `str`, so that a *sequence* of strings is not mistaken for one. + return bool(ANNOTATION_ARGUMENTS) and all( + annotation_argument is str or annotation_argument is type(None) + for annotation_argument in ANNOTATION_ARGUMENTS + ) def _walk_settings_metadata( @@ -744,6 +766,7 @@ def _walk_settings_metadata( description=field.description, requires_restart=EXTRA.get("requires_restart") is True, secret=EXTRA.get("secret") is True, + holds_text=_holds_text(field.annotation), ), ) diff --git a/tests/config/test_document.py b/tests/config/test_document.py index 84a8e7df8..a2a40d0aa 100644 --- a/tests/config/test_document.py +++ b/tests/config/test_document.py @@ -250,6 +250,75 @@ def test_failing_to_write_leaves_the_original_file_intact( assert not [path for path in tmp_path.iterdir() if path.suffix == ".tmp"] +class TestChangingValues: + """Test case for changing the settings held within a document.""" + + @staticmethod + def test_a_setting_is_written_into_an_absent_section(config_file: "Path") -> None: + """Test that setting a value creates whichever sections are needed to hold it.""" + document: SettingsDocument = SettingsDocument.load(config_file) + + document.set_value(["reminders", "send-get-roles-reminders", "interval"], "30m") + + assert document.raw["reminders"]["send-get-roles-reminders"]["interval"] == "30m" + + @staticmethod + def test_a_change_does_not_reach_the_file_until_it_is_written( + config_file: "Path", + ) -> None: + """Test that changing a value alone leaves the file upon disk untouched.""" + document: SettingsDocument = SettingsDocument.load(config_file) + + document.set_value(["community-group", "full-name"], "CompSoc") + + assert config_file.read_text(encoding="utf-8") == MINIMAL_CONFIG + + @staticmethod + def test_a_setting_cannot_be_written_beneath_a_value( + write_config: "ConfigWriter", + ) -> None: + """Test that a section which holds a plain value is not overwritten silently.""" + document: SettingsDocument = SettingsDocument.load( + write_config(f"{MINIMAL_CONFIG}commands: not-a-section\n") + ) + + with pytest.raises(InvalidSettingsFileError, match="not a section"): + document.set_value(["commands", "ping", "easter-egg-probability"], 0.5) + + @staticmethod + def test_a_setting_must_be_named(config_file: "Path") -> None: + """Test that changing a value requires a setting to change.""" + document: SettingsDocument = SettingsDocument.load(config_file) + + with pytest.raises(ValueError, match="must be named"): + document.set_value([], 0.5) + + @staticmethod + def test_an_absent_setting_is_reported_as_not_removed(config_file: "Path") -> None: + """Test that removing a setting the file does not hold reports that it did not.""" + document: SettingsDocument = SettingsDocument.load(config_file) + + assert not document.unset_value(["community-group", "full-name"]) + + @staticmethod + def test_a_copy_can_be_changed_without_affecting_the_original( + config_file: "Path", + ) -> None: + """ + Test that changing a copy of a document leaves the document it came from alone. + + A change is applied to a copy & validated before anything is written, so a + change that turns out to be invalid must not reach the loaded configuration. + """ + document: SettingsDocument = SettingsDocument.load(config_file) + + duplicate_document: SettingsDocument = document.copy() + duplicate_document.set_value(["community-group", "full-name"], "CompSoc") + + assert not document.contains(["community-group", "full-name"]) + assert duplicate_document.contains(["community-group", "full-name"]) + + class TestErrorReporting: """Test case for pointing a human at the cause of an invalid configuration.""" diff --git a/tests/config/test_editor.py b/tests/config/test_editor.py new file mode 100644 index 000000000..e6fc894fe --- /dev/null +++ b/tests/config/test_editor.py @@ -0,0 +1,473 @@ +"""Test suite for changing individual settings within the configuration file.""" + +import datetime +from typing import TYPE_CHECKING + +import pytest + +import config +from config import ( + SettingsValidationError, + UnknownSettingError, +) +from config._accessor import SettingsAccessor +from config._document import SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, SettingsDocument +from config._editor import format_setting_value, parse_setting_value + +from .conftest import ( + CHANGED_EASTER_EGG_PROBABILITY, + DEFAULT_EASTER_EGG_PROBABILITY, + MINIMAL_CONFIG, + VALID_BOT_TOKEN, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from pathlib import Path + from typing import Final + + from config import ConfigReloadResult + + type ConfigWriter = "Callable[[str], Path]" + +__all__: "Sequence[str]" = () + + +COMMENTED_CONFIG: "Final[str]" = f"""\ +# A leading comment about the whole file. +discord: + # A comment about the bot token. + bot-token: {VALID_BOT_TOKEN} + main-guild-id: 1234567890123456789 +community-group: + full-name: Computer Science Society + links: {{}} + msl: {{}} +commands: + ping: + easter-egg-probability: 0.01 +""" + + +@pytest.fixture() +def configured( + write_config: "ConfigWriter", monkeypatch: pytest.MonkeyPatch +) -> "Callable[[str], Path]": + """ + Return a callable writing the given configuration & loading it. + + Changing a setting reads & rewrites whichever file the `TEX_BOT_CONFIG_PATH` + environment variable names, so it is pointed at a temporary file for each test. + """ + monkeypatch.setattr(config, "settings", SettingsAccessor()) + + def _configure(raw_yaml: str) -> "Path": + CONFIG_FILE_PATH: Final[Path] = write_config(raw_yaml) + monkeypatch.setenv(SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, str(CONFIG_FILE_PATH)) + config.reload_settings() + return CONFIG_FILE_PATH + + return _configure + + +class TestParsingValues: + """Test case for reading the value that a committee member typed.""" + + @staticmethod + @pytest.mark.parametrize( + ("raw_value", "expected_value"), + ( + ("true", True), + ("false", False), + ("0.5", 0.5), + ("30", 30), + ("1h30m", "1h30m"), + ("DM", "DM"), + ("[Committee, Member]", ["Committee", "Member"]), + ), + ) + def test_a_value_is_read_as_it_would_be_written( + raw_value: str, expected_value: object + ) -> None: + """Test that a value is read exactly as the same text within the file would be.""" + assert ( + parse_setting_value("commands:strike:timeout-duration", raw_value) + == expected_value + ) + + @staticmethod + @pytest.mark.parametrize( + "setting_name", + # NOTE: One setting declared to hold text & one that is not, because each is + # read by a separate path through the parser. + ("community-group:full-name", "commands:strike:timeout-duration"), + ) + def test_a_value_holding_a_colon_is_kept_as_text(setting_name: str) -> None: + """ + Test that text containing a colon is not read as a section of its own. + + `Computer Science: Society` is a perfectly ordinary name, but is also how a + mapping of one key to one value is written. + """ + assert ( + parse_setting_value(setting_name, "Computer Science: Society") + == "Computer Science: Society" + ) + + @staticmethod + @pytest.mark.parametrize( + "setting_name", + ( + "commands:strike:performed-manually-warning-location", + "commands:strike:timeout-duration", + ), + ) + def test_a_value_beginning_with_a_hash_is_kept_as_text(setting_name: str) -> None: + """Test that text that would otherwise be read as a comment is kept.""" + assert parse_setting_value(setting_name, "#staff") == "#staff" + + @staticmethod + @pytest.mark.parametrize( + "setting_name", + ( + "commands:strike:performed-manually-warning-location", + "commands:strike:timeout-duration", + ), + ) + def test_a_value_that_is_not_valid_yaml_is_kept_as_text(setting_name: str) -> None: + """Test that text which happens to be malformed YAML is kept as the text it is.""" + assert parse_setting_value(setting_name, '"unclosed quote') == '"unclosed quote' + + @staticmethod + def test_every_setting_can_be_named() -> None: + """Test that each setting that exists is offered as one that can be changed.""" + SETTINGS_NAMES: Final[Sequence[str]] = config.documented_setting_names() + + assert "commands:ping:easter-egg-probability" in SETTINGS_NAMES + assert list(SETTINGS_NAMES) == sorted(config.get_settings_metadata()) + + @staticmethod + @pytest.mark.parametrize("raw_value", ("1234", '"1234"', "'1234'")) + def test_a_setting_holding_text_keeps_a_value_that_looks_numeric( + raw_value: str, + ) -> None: + """ + Test that a value which looks like a number stays text where text is expected. + + An MSL organisation ID is four or five digits, so somebody setting one will + write it exactly as they see it, without quoting it as YAML would require. + """ + assert parse_setting_value("community-group:msl:organisation-id", raw_value) == "1234" + + @staticmethod + def test_a_setting_holding_a_sequence_is_not_mistaken_for_text() -> None: + """Test that a list of role names is still read as a list.""" + assert parse_setting_value("commands:stats:displayed-roles", "[Committee]") == [ + "Committee" + ] + + @staticmethod + def test_an_unknown_setting_is_refused() -> None: + """Test that reading a value for a setting that does not exist is refused.""" + with pytest.raises(UnknownSettingError, match="not:a:setting"): + parse_setting_value("not:a:setting", "1") + + +class TestChangingSettings: + """Test case for writing a single setting into the configuration file.""" + + @staticmethod + def test_a_changed_setting_is_applied( + configured: "Callable[[str], Path]", + ) -> None: + """Test that changing a setting takes effect immediately.""" + configured(COMMENTED_CONFIG) + + RESULT: Final[ConfigReloadResult] = config.set_setting( + "commands:ping:easter-egg-probability", "0.5" + ) + + assert RESULT.changed_settings == {"commands:ping:easter-egg-probability"} + assert ( + config.settings.commands.ping.easter_egg_probability + == CHANGED_EASTER_EGG_PROBABILITY + ) + + @staticmethod + def test_a_changed_setting_is_written_to_the_file( + configured: "Callable[[str], Path]", + ) -> None: + """Test that a changed setting survives being loaded again.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + config.set_setting("commands:ping:easter-egg-probability", "0.5") + + assert ( + SettingsDocument.load(CONFIG_FILE_PATH).raw["commands"]["ping"][ + "easter-egg-probability" + ] + == CHANGED_EASTER_EGG_PROBABILITY + ) + + @staticmethod + def test_changing_a_setting_preserves_every_comment( + configured: "Callable[[str], Path]", + ) -> None: + """Test that rewriting the file keeps the annotations a human wrote within it.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + config.set_setting("commands:ping:easter-egg-probability", "0.5") + + NEW_FILE_CONTENTS: Final[str] = CONFIG_FILE_PATH.read_text(encoding="utf-8") + + assert "# A leading comment about the whole file." in NEW_FILE_CONTENTS + assert "# A comment about the bot token." in NEW_FILE_CONTENTS + + @staticmethod + def test_a_setting_within_an_absent_section_is_created( + configured: "Callable[[str], Path]", + ) -> None: + """Test that setting a value creates whichever sections are needed to hold it.""" + configured(MINIMAL_CONFIG) + + config.set_setting("reminders:send-get-roles-reminders:interval", "30m") + + assert config.settings.reminders.send_get_roles_reminders.interval == ( + datetime.timedelta(minutes=30) + ) + + @staticmethod + def test_a_change_needing_a_restart_is_reported( + configured: "Callable[[str], Path]", + ) -> None: + """Test that changing a setting fixed at start-up says a restart is needed.""" + configured(COMMENTED_CONFIG) + + RESULT: Final[ConfigReloadResult] = config.set_setting( + "reminders:send-get-roles-reminders:interval", "30m" + ) + + assert RESULT.restart_required_settings == { + "reminders:send-get-roles-reminders:interval" + } + + @staticmethod + def test_an_unknown_setting_is_refused(configured: "Callable[[str], Path]") -> None: + """Test that changing a setting that does not exist is refused.""" + configured(COMMENTED_CONFIG) + + with pytest.raises(UnknownSettingError): + config.set_setting("not:a:setting", "1") + + +class TestRejectedChanges: + """Test case for refusing a change that would produce an invalid configuration.""" + + @staticmethod + def test_an_invalid_value_is_rejected(configured: "Callable[[str], Path]") -> None: + """Test that a value outside what a setting allows is refused.""" + configured(COMMENTED_CONFIG) + + with pytest.raises(SettingsValidationError): + config.set_setting("commands:ping:easter-egg-probability", "9.9") + + @staticmethod + def test_a_rejected_change_does_not_touch_the_file( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that a refused change leaves the configuration file exactly as it was. + + The change is validated against a copy of the file, so a value that turns out to + be invalid never reaches the file that TeX-Bot would load next. + """ + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + with pytest.raises(SettingsValidationError): + config.set_setting("commands:ping:easter-egg-probability", "9.9") + + assert CONFIG_FILE_PATH.read_text(encoding="utf-8") == COMMENTED_CONFIG + + @staticmethod + def test_a_rejected_change_leaves_the_running_configuration_alone( + configured: "Callable[[str], Path]", + ) -> None: + """Test that a refused change does not disturb the settings already loaded.""" + configured(COMMENTED_CONFIG) + + with pytest.raises(SettingsValidationError): + config.set_setting("commands:ping:easter-egg-probability", "9.9") + + assert ( + config.settings.commands.ping.easter_egg_probability + == DEFAULT_EASTER_EGG_PROBABILITY + ) + + @staticmethod + def test_a_rejected_new_setting_is_reported_without_a_line_number( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that refusing a setting absent from the file is reported, not crashed upon. + + A setting being written for the first time has no line within the file it came + from, so there is no line number to report it against. + """ + configured(MINIMAL_CONFIG) + + with pytest.raises(SettingsValidationError) as validation_error: + config.set_setting("reminders:send-get-roles-reminders:interval", "0s") + + assert "reminders:send-get-roles-reminders:interval" in str(validation_error.value) + + @staticmethod + def test_a_change_made_by_hand_is_not_overwritten( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that a setting edited by hand since loading survives a later change. + + The file is read again before being changed, rather than the copy held in + memory being rewritten, so an edit made in the meantime is kept. + """ + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + CONFIG_FILE_PATH.write_text( + COMMENTED_CONFIG.replace( + "full-name: Computer Science Society", "full-name: Edited By Hand" + ), + encoding="utf-8", + ) + + config.set_setting("commands:ping:easter-egg-probability", "0.5") + + assert config.settings.community_group.full_name == "Edited By Hand" + assert ( + config.settings.commands.ping.easter_egg_probability + == CHANGED_EASTER_EGG_PROBABILITY + ) + + +class TestRemovingSettings: + """Test case for returning a single setting to its default value.""" + + @staticmethod + def test_a_removed_setting_returns_to_its_default( + configured: "Callable[[str], Path]", + ) -> None: + """Test that removing a setting restores the value it would otherwise have.""" + configured(COMMENTED_CONFIG) + + RESULT: Final[ConfigReloadResult | None] = config.unset_setting( + "community-group:full-name" + ) + + assert RESULT is not None + assert RESULT.changed_settings == {"community-group:full-name"} + assert config.settings.community_group.full_name is None + + @staticmethod + def test_removing_a_setting_that_is_not_set_changes_nothing( + configured: "Callable[[str], Path]", + ) -> None: + """Test that removing a setting absent from the file is reported as such.""" + CONFIG_FILE_PATH: Final[Path] = configured(MINIMAL_CONFIG) + + assert config.unset_setting("community-group:full-name") is None + assert CONFIG_FILE_PATH.read_text(encoding="utf-8") == MINIMAL_CONFIG + + @staticmethod + def test_removing_an_unknown_setting_is_refused( + configured: "Callable[[str], Path]", + ) -> None: + """Test that removing a setting that does not exist is refused.""" + configured(COMMENTED_CONFIG) + + with pytest.raises(UnknownSettingError): + config.unset_setting("not:a:setting") + + @staticmethod + def test_removing_a_required_setting_is_refused( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that a setting TeX-Bot cannot run without is not removed. + + No special knowledge of which settings are required is needed: the configuration + that removing it would produce simply does not validate. + """ + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + with pytest.raises(SettingsValidationError): + config.unset_setting("discord:bot-token") + + assert CONFIG_FILE_PATH.read_text(encoding="utf-8") == COMMENTED_CONFIG + assert config.settings.discord.bot_token.get_secret_value() == VALID_BOT_TOKEN + + @staticmethod + def test_a_section_left_empty_is_still_valid( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that removing the last setting of a section leaves a usable file. + + A section holding nothing is kept rather than removed, because a section may be + required to be present even when every setting within it is left at its default. + """ + configured(COMMENTED_CONFIG) + + config.unset_setting("commands:ping:easter-egg-probability") + + assert ( + config.settings.commands.ping.easter_egg_probability + == DEFAULT_EASTER_EGG_PROBABILITY + ) + assert config.reload_settings().changed_settings == set() + + +class TestDisplayingValues: + """Test case for rendering a setting's value into something readable.""" + + @staticmethod + @pytest.mark.parametrize( + ("duration", "expected_display"), + ( + (datetime.timedelta(hours=24), "`1d`"), + (datetime.timedelta(minutes=30), "`30m`"), + (datetime.timedelta(hours=1, minutes=30), "`1h30m`"), + (datetime.timedelta(days=1, hours=16), "`1d16h`"), + (datetime.timedelta(seconds=45), "`45s`"), + (datetime.timedelta(0), "`0s`"), + ), + ) + def test_a_duration_is_shown_in_the_format_it_is_written_in( + duration: datetime.timedelta, expected_display: str + ) -> None: + """Test that a length of time is shown the way it would be written in the file.""" + assert format_setting_value(duration, secret=False) == expected_display + + @staticmethod + def test_a_secret_value_is_never_shown() -> None: + """Test that displaying a secret setting does not reveal it.""" + FORMATTED_VALUE: Final[str] = format_setting_value(VALID_BOT_TOKEN, secret=True) + + assert VALID_BOT_TOKEN not in FORMATTED_VALUE + + @staticmethod + def test_an_unset_value_is_shown_as_unset() -> None: + """Test that a setting holding nothing says so, rather than showing nothing.""" + assert "not set" in format_setting_value(None, secret=False) + + @staticmethod + @pytest.mark.parametrize( + ("value", "expected_display"), + ( + (True, "`true`"), + (False, "`false`"), + (("Committee", "Member"), "`Committee`, `Member`"), + (0.01, "`0.01`"), + ), + ) + def test_a_value_is_shown_as_it_is_written(value: object, expected_display: str) -> None: + """Test that each kind of value is shown the way it appears within the file.""" + assert format_setting_value(value, secret=False) == expected_display diff --git a/tex-bot-deployment.example.yaml b/tex-bot-deployment.example.yaml index 05fd73349..c2ba38b40 100644 --- a/tex-bot-deployment.example.yaml +++ b/tex-bot-deployment.example.yaml @@ -14,6 +14,11 @@ # task set to repeat every `0s` would run continuously without ever pausing. # # Run `/config reload` after editing this file to apply your changes. +# +# Individual settings can also be changed from within Discord, without editing this file +# by hand: `/config get` shows what a setting is currently set to, `/config set` changes +# one, and `/config unset` returns one to its default. Each of them is committee-only, +# and writes back to this file while keeping the comments you have added to it. # Most settings take effect straight away, because they are read at the moment they # are used. A few cannot, and `/config reload` will tell you when a restart is needed: # From c72b626364e94fcba8a8cd9cf6f5b4fc65c9a69c Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:12:59 +0100 Subject: [PATCH 24/33] Cover how a change from Discord meets one made by hand Changing a setting from within Discord reads the configuration file afresh & applies the change on top of it, so the two kinds of change compose: * Where they affect different settings, both are kept. * Where they affect the same setting, the change made from within Discord wins, being the later of the two & the one somebody is waiting upon. * A setting added or removed by hand is likewise kept. * A mistake made by hand anywhere within the file blocks the change & is reported, rather than being written back. Each of these is now covered, & each was checked by making the change apply to the configuration already loaded instead: four of the six then fail. Also drops the copy taken before applying a change. Reading the file again already produces a document that nothing else holds a reference to, so copying it was pure duplication; the safety it was there for comes from reading afresh, not from the copy. This removes roughly an eighth of the time a single change takes (16ms, against a Discord round-trip). --- config/_document.py | 10 --- config/_editor.py | 16 ++-- tests/config/test_document.py | 19 ++--- tests/config/test_editor.py | 138 +++++++++++++++++++++++++++++++--- 4 files changed, 147 insertions(+), 36 deletions(-) diff --git a/config/_document.py b/config/_document.py index ec13bf5b5..e1b3fe686 100644 --- a/config/_document.py +++ b/config/_document.py @@ -9,7 +9,6 @@ the shape & meaning of the configuration is declared solely within `config._schema`. """ -import copy import io import logging import os @@ -176,15 +175,6 @@ def raw(self) -> "CommentedMap": """ return self._raw - def copy(self) -> "Self": - """ - Return an independent copy of this document, retaining its comments & formatting. - - Used to apply a change to a copy & validate the result, so that a change which - turns out to be invalid never reaches the document that is currently loaded. - """ - return type(self)(file_path=self._file_path, raw=copy.deepcopy(self._raw)) - def contains(self, key_path: "Sequence[str]") -> bool: """Whether the given sequence of keys is written within this document.""" node: object = self._raw diff --git a/config/_editor.py b/config/_editor.py index 36ea13585..eb70f372a 100644 --- a/config/_editor.py +++ b/config/_editor.py @@ -1,9 +1,10 @@ """ Changing individual settings within the deployment configuration file. -Every change is applied to a copy of the configuration document & validated before -anything is written, so that a mistaken value can never leave TeX-Bot holding a -configuration file it would refuse to load. +Every change is applied to the configuration file read afresh, & validated in full, +before anything is written, so that a mistaken value can never leave TeX-Bot holding a +configuration file it would refuse to load. Reading the file again also means a change +made to it by hand is kept, rather than being overwritten by whatever was last loaded. This module holds no knowledge of Discord: it turns the text a committee member typed into a value, decides whether that value is acceptable, & renders values back into @@ -181,14 +182,15 @@ def validated_document_with_setting_set(setting_name: str, value: object) -> Set """ Return the configuration file, holding the given value for the given setting. - The file is read again rather than reusing the configuration already loaded, so that - any change made to it by hand since then is kept rather than being overwritten. + The file is read afresh rather than reusing the configuration already loaded, both + so that any change made to it by hand since then is kept rather than overwritten, + and so that the change is applied to a document that nothing else is using. Nothing is written to disk: the returned document must be written by its caller. """ KEY_PATH: Final[Sequence[str]] = _key_path_of(setting_name) - document: SettingsDocument = SettingsDocument.load().copy() + document: SettingsDocument = SettingsDocument.load() document.set_value(KEY_PATH, value) return _validated(document) @@ -205,7 +207,7 @@ def validated_document_with_setting_removed(setting_name: str) -> SettingsDocume """ KEY_PATH: Final[Sequence[str]] = _key_path_of(setting_name) - document: SettingsDocument = SettingsDocument.load().copy() + document: SettingsDocument = SettingsDocument.load() if not document.unset_value(KEY_PATH): return None diff --git a/tests/config/test_document.py b/tests/config/test_document.py index a2a40d0aa..434e33c17 100644 --- a/tests/config/test_document.py +++ b/tests/config/test_document.py @@ -301,22 +301,23 @@ def test_an_absent_setting_is_reported_as_not_removed(config_file: "Path") -> No assert not document.unset_value(["community-group", "full-name"]) @staticmethod - def test_a_copy_can_be_changed_without_affecting_the_original( + def test_a_document_read_again_is_independent_of_the_one_already_held( config_file: "Path", ) -> None: """ - Test that changing a copy of a document leaves the document it came from alone. + Test that changing a freshly read document leaves an existing one alone. - A change is applied to a copy & validated before anything is written, so a - change that turns out to be invalid must not reach the loaded configuration. + A change is applied to the file read afresh & validated before anything is + written, so a change that turns out to be invalid must not be able to reach the + configuration that is currently loaded. """ - document: SettingsDocument = SettingsDocument.load(config_file) + loaded_document: SettingsDocument = SettingsDocument.load(config_file) - duplicate_document: SettingsDocument = document.copy() - duplicate_document.set_value(["community-group", "full-name"], "CompSoc") + document_being_changed: SettingsDocument = SettingsDocument.load(config_file) + document_being_changed.set_value(["community-group", "full-name"], "CompSoc") - assert not document.contains(["community-group", "full-name"]) - assert duplicate_document.contains(["community-group", "full-name"]) + assert not loaded_document.contains(["community-group", "full-name"]) + assert document_being_changed.contains(["community-group", "full-name"]) class TestErrorReporting: diff --git a/tests/config/test_editor.py b/tests/config/test_editor.py index e6fc894fe..40f7e34ea 100644 --- a/tests/config/test_editor.py +++ b/tests/config/test_editor.py @@ -320,32 +320,150 @@ def test_a_rejected_new_setting_is_reported_without_a_line_number( assert "reminders:send-get-roles-reminders:interval" in str(validation_error.value) + +class TestChangesMadeByHand: + """ + Test case for a change made from within Discord meeting one made by hand. + + The configuration file is read again immediately before each change is applied, + rather than the copy held in memory being rewritten, so an edit made by hand since + the configuration was last loaded is never silently discarded. + """ + + @staticmethod + def _edit_by_hand( + config_file_path: "Path", original: str, replacement: str + ) -> None: + """Edit the configuration file directly, as somebody with a text editor would.""" + config_file_path.write_text( + config_file_path.read_text(encoding="utf-8").replace(original, replacement), + encoding="utf-8", + ) + + @staticmethod + def test_a_change_to_a_different_setting_keeps_both( + configured: "Callable[[str], Path]", + ) -> None: + """Test that changes to two different settings are both kept.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + TestChangesMadeByHand._edit_by_hand( + CONFIG_FILE_PATH, + "full-name: Computer Science Society", + "full-name: Edited By Hand", + ) + + config.set_setting("commands:ping:easter-egg-probability", "0.5") + + assert config.settings.community_group.full_name == "Edited By Hand" + assert ( + config.settings.commands.ping.easter_egg_probability + == CHANGED_EASTER_EGG_PROBABILITY + ) + @staticmethod - def test_a_change_made_by_hand_is_not_overwritten( + def test_a_change_to_the_same_setting_takes_priority( configured: "Callable[[str], Path]", ) -> None: """ - Test that a setting edited by hand since loading survives a later change. + Test that changing a setting from within Discord overrules an edit made by hand. - The file is read again before being changed, rather than the copy held in - memory being rewritten, so an edit made in the meantime is kept. + Both changes are to the same setting, so one of them has to win: the change made + from within Discord is the later of the two, & is the one its author is waiting + upon a response to. """ CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) - CONFIG_FILE_PATH.write_text( - COMMENTED_CONFIG.replace( - "full-name: Computer Science Society", "full-name: Edited By Hand" - ), - encoding="utf-8", + TestChangesMadeByHand._edit_by_hand( + CONFIG_FILE_PATH, + "easter-egg-probability: 0.01", + "easter-egg-probability: 0.25", ) config.set_setting("commands:ping:easter-egg-probability", "0.5") - assert config.settings.community_group.full_name == "Edited By Hand" assert ( config.settings.commands.ping.easter_egg_probability == CHANGED_EASTER_EGG_PROBABILITY ) + assert "0.25" not in CONFIG_FILE_PATH.read_text(encoding="utf-8") + + @staticmethod + def test_a_setting_added_by_hand_survives_a_later_change( + configured: "Callable[[str], Path]", + ) -> None: + """Test that a setting added by hand is kept when another one is changed.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + TestChangesMadeByHand._edit_by_hand( + CONFIG_FILE_PATH, + "community-group:\n", + "auto-add-committee-to-threads: false\ncommunity-group:\n", + ) + + config.set_setting("commands:ping:easter-egg-probability", "0.5") + + assert config.settings.auto_add_committee_to_threads is False + + @staticmethod + def test_a_setting_removed_by_hand_stays_removed( + configured: "Callable[[str], Path]", + ) -> None: + """Test that a setting deleted by hand is not brought back by a later change.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + TestChangesMadeByHand._edit_by_hand( + CONFIG_FILE_PATH, " full-name: Computer Science Society\n", "" + ) + + config.set_setting("commands:ping:easter-egg-probability", "0.5") + + assert config.settings.community_group.full_name is None + + @staticmethod + def test_an_edit_made_by_hand_survives_a_removal( + configured: "Callable[[str], Path]", + ) -> None: + """Test that removing one setting keeps an edit made by hand to another.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + TestChangesMadeByHand._edit_by_hand( + CONFIG_FILE_PATH, + "full-name: Computer Science Society", + "full-name: Edited By Hand", + ) + + config.unset_setting("commands:ping:easter-egg-probability") + + assert config.settings.community_group.full_name == "Edited By Hand" + assert ( + config.settings.commands.ping.easter_egg_probability + == DEFAULT_EASTER_EGG_PROBABILITY + ) + + @staticmethod + def test_an_edit_made_by_hand_that_is_invalid_blocks_the_change( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that a change is refused while the file holds a mistake made by hand. + + The whole configuration is validated, not merely the setting being changed, so a + mistake elsewhere within the file is reported rather than written back. + """ + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + TestChangesMadeByHand._edit_by_hand( + CONFIG_FILE_PATH, "main-guild-id: 1234567890123456789", "main-guild-id: 12" + ) + CONFIG_FILE_CONTENTS_AFTER_EDIT: Final[str] = CONFIG_FILE_PATH.read_text( + encoding="utf-8" + ) + + with pytest.raises(SettingsValidationError, match="main-guild-id"): + config.set_setting("commands:ping:easter-egg-probability", "0.5") + + assert CONFIG_FILE_PATH.read_text(encoding="utf-8") == CONFIG_FILE_CONTENTS_AFTER_EDIT class TestRemovingSettings: From fb446ec5cc5f5455c385736d96138a217437fef3 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:21:20 +0100 Subject: [PATCH 25/33] Refuse to change a configuration file that has been edited by hand Changing a setting from within Discord previously read the file afresh & merged the change into whatever it found there. That quietly applied somebody else's unrelated edits as a side effect of an unrelated change: the response named only the setting its author had asked for, yet the reload behind it applied the whole file. An edit to a setting fixed at start-up was worse still, reporting that TeX-Bot had to be restarted for a setting whoever ran the command had never touched. Both `/config set` & `/config unset` now refuse a file that has been edited since it was last loaded, & say to run `/config reload` first. Reloading replaces the document held alongside the settings, so it clears the refusal even for an edit that changed no setting at all. `/config get` keeps working, but says when the value it is showing may no longer match the file, rather than showing a stale value without comment. It reports what TeX-Bot is running, which is the honest answer. The comparison is made between the documents the files parse to, rather than their raw text, so a file rewritten with different line endings is not mistaken for one whose settings were edited. All of this leaves every `/config` command acting upon the configuration that TeX-Bot has actually loaded, which is a far simpler rule to hold in mind than the merge it replaces. --- cogs/config.py | 31 +++++++ config/__init__.py | 17 +++- config/_accessor.py | 12 +++ config/_editor.py | 53 ++++++++++-- tests/config/test_accessor.py | 10 +++ tests/config/test_editor.py | 146 ++++++++++++++++++-------------- tex-bot-deployment.example.yaml | 5 ++ 7 files changed, 198 insertions(+), 76 deletions(-) diff --git a/cogs/config.py b/cogs/config.py index 68b4df329..607ec3f16 100644 --- a/cogs/config.py +++ b/cogs/config.py @@ -8,6 +8,7 @@ import config from config import ( InvalidSettingsFileError, + SettingsFileChangedError, SettingsFileNotFoundError, SettingsNotLoadedError, SettingsValidationError, @@ -227,6 +228,16 @@ async def get(self, ctx: "TeXBotApplicationContext", setting_name: str) -> None: "\n\n:warning: Changing this setting requires TeX-Bot to be restarted." ) + # NOTE: The value shown is the one currently in use, which is the one that was + # loaded. Saying so is what stops an edit made to the file since then from + # looking as though it had simply been ignored. + if config.settings.file_has_changed(): + response_message += ( + "\n\n:warning: The configuration file has been edited since TeX-Bot " + "last loaded it, so the value shown above may no longer match it. " + "Run `/config reload` to load those edits." + ) + await ctx.respond(response_message, ephemeral=True) @config.command( @@ -270,6 +281,9 @@ async def set( except UnknownSettingError as unknown_setting_error: await self._respond_with_unknown_setting(ctx, unknown_setting_error) return + except SettingsFileChangedError: + await self._respond_with_changed_file(ctx) + return except SettingsValidationError as validation_error: await self._respond_with_rejected_change(ctx, setting_name, validation_error) return @@ -330,6 +344,9 @@ async def unset(self, ctx: "TeXBotApplicationContext", setting_name: str) -> Non except UnknownSettingError as unknown_setting_error: await self._respond_with_unknown_setting(ctx, unknown_setting_error) return + except SettingsFileChangedError: + await self._respond_with_changed_file(ctx) + return except SettingsValidationError as validation_error: await ctx.respond( ( @@ -389,6 +406,20 @@ async def _respond_with_unknown_setting( ephemeral=True, ) + @staticmethod + async def _respond_with_changed_file(ctx: "TeXBotApplicationContext") -> None: + """Explain that the configuration file has been edited since it was loaded.""" + logger.info("A change was refused because the configuration file had been edited.") + + await ctx.respond( + ( + ":x: The configuration file has been edited since TeX-Bot last loaded " + "it, so nothing has been changed.\n" + "Run `/config reload` to load those edits first, then try again." + ), + ephemeral=True, + ) + @staticmethod async def _respond_with_rejected_change( ctx: "TeXBotApplicationContext", diff --git a/config/__init__.py b/config/__init__.py index 27de2e790..f7f996d4e 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -20,6 +20,7 @@ ) from ._editor import ( SETTING_NAME_SEPARATOR, + SettingsFileChangedError, UnknownSettingError, documented_setting_names, format_setting_value, @@ -45,6 +46,7 @@ "ConfigSettingMetadata", "InvalidSettingsFileError", "SettingsDocument", + "SettingsFileChangedError", "SettingsFileNotFoundError", "SettingsNotLoadedError", "SettingsValidationError", @@ -107,6 +109,11 @@ def reload_settings() -> ConfigReloadResult: ) +def _loaded_document() -> "SettingsDocument | None": + """Return the configuration document currently loaded, if any configuration has been.""" + return settings.document if settings.is_loaded else None + + def set_setting(setting_name: str, raw_value: str) -> ConfigReloadResult: """ Change a single setting within the configuration file, then apply the change. @@ -114,11 +121,14 @@ def set_setting(setting_name: str, raw_value: str) -> ConfigReloadResult: The new value is validated before the file is written, so a value that would be rejected leaves both the file & the running configuration exactly as they were. + Changing a file that has been edited by hand since it was last loaded is refused, + so that a change is only ever made against the configuration TeX-Bot is running. + Returns the settings that changed as a result, along with the subset of those that cannot take effect until TeX-Bot is restarted. """ UPDATED_DOCUMENT: Final[SettingsDocument] = validated_document_with_setting_set( - setting_name, parse_setting_value(setting_name, raw_value) + setting_name, parse_setting_value(setting_name, raw_value), _loaded_document() ) UPDATED_DOCUMENT.write() @@ -134,10 +144,11 @@ def unset_setting(setting_name: str) -> ConfigReloadResult | None: not written within the file to begin with, so nothing needed to be removed. Removing a setting that is required leaves the file untouched, because the - configuration that removing it would produce is not valid. + configuration that removing it would produce is not valid. Removing a setting from a + file that has been edited by hand since it was last loaded is likewise refused. """ UPDATED_DOCUMENT: Final[SettingsDocument | None] = validated_document_with_setting_removed( - setting_name + setting_name, _loaded_document() ) if UPDATED_DOCUMENT is None: diff --git a/config/_accessor.py b/config/_accessor.py index f52b62e16..ecca0ecb4 100644 --- a/config/_accessor.py +++ b/config/_accessor.py @@ -130,6 +130,18 @@ def document(self) -> SettingsDocument: """ return self._current.document + def file_has_changed(self) -> bool: + """ + Whether the configuration file differs from the configuration loaded from it. + + Compared as the documents parse to, rather than as raw text, so that a file + rewritten with different line endings is not mistaken for one that was edited. + """ + if self._loaded is None: + return False + + return SettingsDocument.load(self.file_path).dump() != self._loaded.document.dump() + def reload(self, file_path: "Path | None" = None) -> "AbstractSet[str]": """ Load the configuration file, replacing any previously loaded configuration. diff --git a/config/_editor.py b/config/_editor.py index eb70f372a..333fb1f63 100644 --- a/config/_editor.py +++ b/config/_editor.py @@ -3,8 +3,9 @@ Every change is applied to the configuration file read afresh, & validated in full, before anything is written, so that a mistaken value can never leave TeX-Bot holding a -configuration file it would refuse to load. Reading the file again also means a change -made to it by hand is kept, rather than being overwritten by whatever was last loaded. +configuration file it would refuse to load. A file that has been changed by hand since +it was last loaded is refused outright, rather than merged into, so that every change +is made against the configuration that TeX-Bot is actually running. This module holds no knowledge of Discord: it turns the text a committee member typed into a value, decides whether that value is acceptable, & renders values back into @@ -32,6 +33,7 @@ __all__: "Sequence[str]" = ( "SETTING_NAME_SEPARATOR", + "SettingsFileChangedError", "UnknownSettingError", "documented_setting_names", "format_setting_value", @@ -63,6 +65,16 @@ def __init__(self, setting_name: str) -> None: super().__init__(f"No configuration setting is named {setting_name!r}.") +class SettingsFileChangedError(Exception): + """Exception class to raise when the configuration file has been edited by hand.""" + + def __init__(self) -> None: + """Initialise a new SettingsFileChangedError.""" + super().__init__( + "The configuration file has been changed since TeX-Bot last loaded it." + ) + + def documented_setting_names() -> "Sequence[str]": """Return the name of every setting that can be viewed or changed, in order.""" return sorted(get_settings_metadata()) @@ -178,36 +190,59 @@ def _validated(document: SettingsDocument) -> SettingsDocument: return document -def validated_document_with_setting_set(setting_name: str, value: object) -> SettingsDocument: +def _read_unchanged_file(loaded_document: SettingsDocument | None) -> SettingsDocument: + """ + Read the configuration file again, refusing it if it no longer matches what is loaded. + + A change made by hand is refused rather than merged into, so that changing one + setting cannot silently apply somebody else's unrelated edits alongside it, nor + report that TeX-Bot must be restarted for a setting its author never touched. + """ + document: SettingsDocument = SettingsDocument.load() + + # NOTE: Nothing has been loaded for the file to have diverged from while TeX-Bot is + # still starting up, so there is nothing to compare it against. + if loaded_document is not None and document.dump() != loaded_document.dump(): + raise SettingsFileChangedError + + return document + + +def validated_document_with_setting_set( + setting_name: str, value: object, loaded_document: SettingsDocument | None = None +) -> SettingsDocument: """ Return the configuration file, holding the given value for the given setting. - The file is read afresh rather than reusing the configuration already loaded, both - so that any change made to it by hand since then is kept rather than overwritten, - and so that the change is applied to a document that nothing else is using. + The file is refused if it has been changed by hand since it was last loaded, so that + a change is only ever applied to the configuration TeX-Bot is actually running. Nothing is written to disk: the returned document must be written by its caller. """ KEY_PATH: Final[Sequence[str]] = _key_path_of(setting_name) - document: SettingsDocument = SettingsDocument.load() + document: SettingsDocument = _read_unchanged_file(loaded_document) document.set_value(KEY_PATH, value) return _validated(document) -def validated_document_with_setting_removed(setting_name: str) -> SettingsDocument | None: +def validated_document_with_setting_removed( + setting_name: str, loaded_document: SettingsDocument | None = None +) -> SettingsDocument | None: """ Return the configuration file, no longer holding the given setting. `None` is returned where the setting was not written within the file to begin with, because removing it would leave the file exactly as it already is. + The file is refused if it has been changed by hand since it was last loaded. + Nothing is written to disk: the returned document must be written by its caller. """ KEY_PATH: Final[Sequence[str]] = _key_path_of(setting_name) - document: SettingsDocument = SettingsDocument.load() + document: SettingsDocument = _read_unchanged_file(loaded_document) if not document.unset_value(KEY_PATH): return None diff --git a/tests/config/test_accessor.py b/tests/config/test_accessor.py index 4ac4d1ed1..a13ad61ad 100644 --- a/tests/config/test_accessor.py +++ b/tests/config/test_accessor.py @@ -68,6 +68,16 @@ def test_accessing_settings_before_loading_is_refused() -> None: with pytest.raises(SettingsNotLoadedError): _ = settings.discord.main_guild_id + @staticmethod + def test_nothing_loaded_has_not_been_changed_underneath() -> None: + """ + Test that an accessor holding no configuration reports no change to the file. + + There is nothing loaded for a file to have diverged from, which is the state + TeX-Bot is in while it is still starting up. + """ + assert not SettingsAccessor().file_has_changed() + @staticmethod def test_loading_makes_settings_available(config_file: "Path") -> None: """Test that settings can be read once a configuration has been loaded.""" diff --git a/tests/config/test_editor.py b/tests/config/test_editor.py index 40f7e34ea..04e7d7cf3 100644 --- a/tests/config/test_editor.py +++ b/tests/config/test_editor.py @@ -7,6 +7,7 @@ import config from config import ( + SettingsFileChangedError, SettingsValidationError, UnknownSettingError, ) @@ -325,15 +326,13 @@ class TestChangesMadeByHand: """ Test case for a change made from within Discord meeting one made by hand. - The configuration file is read again immediately before each change is applied, - rather than the copy held in memory being rewritten, so an edit made by hand since - the configuration was last loaded is never silently discarded. + A configuration file that has been edited since it was last loaded is refused rather + than merged into, so that a change is only ever made against the configuration that + TeX-Bot is actually running. """ @staticmethod - def _edit_by_hand( - config_file_path: "Path", original: str, replacement: str - ) -> None: + def _edit_by_hand(config_file_path: "Path", original: str, replacement: str) -> None: """Edit the configuration file directly, as somebody with a text editor would.""" config_file_path.write_text( config_file_path.read_text(encoding="utf-8").replace(original, replacement), @@ -341,10 +340,43 @@ def _edit_by_hand( ) @staticmethod - def test_a_change_to_a_different_setting_keeps_both( + @pytest.mark.parametrize( + ("original", "replacement"), + ( + # NOTE: An edit to the setting being changed, to a different setting, one + # adding a setting, one removing a setting, & one changing nothing at all. + ("easter-egg-probability: 0.01", "easter-egg-probability: 0.25"), + ("full-name: Computer Science Society", "full-name: Edited By Hand"), + ("community-group:\n", "auto-add-committee-to-threads: false\ncommunity-group:\n"), + (" full-name: Computer Science Society\n", ""), + ("discord:\n", "# A comment added by hand.\ndiscord:\n"), + ), + ) + def test_a_change_is_refused_while_the_file_has_been_edited( + configured: "Callable[[str], Path]", original: str, replacement: str + ) -> None: + """Test that any edit made by hand refuses a change made from within Discord.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + TestChangesMadeByHand._edit_by_hand(CONFIG_FILE_PATH, original, replacement) + CONFIG_FILE_CONTENTS_AFTER_EDIT: Final[str] = CONFIG_FILE_PATH.read_text( + encoding="utf-8" + ) + + with pytest.raises(SettingsFileChangedError): + config.set_setting("commands:ping:easter-egg-probability", "0.5") + + assert CONFIG_FILE_PATH.read_text(encoding="utf-8") == CONFIG_FILE_CONTENTS_AFTER_EDIT + assert ( + config.settings.commands.ping.easter_egg_probability + == DEFAULT_EASTER_EGG_PROBABILITY + ) + + @staticmethod + def test_a_removal_is_refused_while_the_file_has_been_edited( configured: "Callable[[str], Path]", ) -> None: - """Test that changes to two different settings are both kept.""" + """Test that an edit made by hand refuses a removal too, not only a change.""" CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) TestChangesMadeByHand._edit_by_hand( @@ -353,117 +385,103 @@ def test_a_change_to_a_different_setting_keeps_both( "full-name: Edited By Hand", ) - config.set_setting("commands:ping:easter-egg-probability", "0.5") + with pytest.raises(SettingsFileChangedError): + config.unset_setting("commands:ping:easter-egg-probability") - assert config.settings.community_group.full_name == "Edited By Hand" - assert ( - config.settings.commands.ping.easter_egg_probability - == CHANGED_EASTER_EGG_PROBABILITY - ) + assert config.settings.community_group.full_name == "Computer Science Society" @staticmethod - def test_a_change_to_the_same_setting_takes_priority( + def test_reloading_first_allows_the_change_to_be_made( configured: "Callable[[str], Path]", ) -> None: """ - Test that changing a setting from within Discord overrules an edit made by hand. + Test that loading the edits made by hand is enough to allow a change again. - Both changes are to the same setting, so one of them has to win: the change made - from within Discord is the later of the two, & is the one its author is waiting - upon a response to. + This is what the refusal tells whoever ran the command to do, so it has to be + the case that doing it lets them carry on. """ CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) TestChangesMadeByHand._edit_by_hand( CONFIG_FILE_PATH, - "easter-egg-probability: 0.01", - "easter-egg-probability: 0.25", + "full-name: Computer Science Society", + "full-name: Edited By Hand", ) + config.reload_settings() config.set_setting("commands:ping:easter-egg-probability", "0.5") + assert config.settings.community_group.full_name == "Edited By Hand" assert ( config.settings.commands.ping.easter_egg_probability == CHANGED_EASTER_EGG_PROBABILITY ) - assert "0.25" not in CONFIG_FILE_PATH.read_text(encoding="utf-8") @staticmethod - def test_a_setting_added_by_hand_survives_a_later_change( + def test_a_comment_added_by_hand_is_cleared_by_reloading( configured: "Callable[[str], Path]", ) -> None: - """Test that a setting added by hand is kept when another one is changed.""" + """ + Test that reloading clears the refusal even for an edit that changed no setting. + + Reloading replaces the document it holds as well as the settings taken from it, + so an edit that altered only the comments no longer counts as outstanding. + """ CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) TestChangesMadeByHand._edit_by_hand( - CONFIG_FILE_PATH, - "community-group:\n", - "auto-add-committee-to-threads: false\ncommunity-group:\n", + CONFIG_FILE_PATH, "discord:\n", "# A comment added by hand.\ndiscord:\n" ) + assert config.reload_settings().changed_settings == set() + config.set_setting("commands:ping:easter-egg-probability", "0.5") - assert config.settings.auto_add_committee_to_threads is False + assert "# A comment added by hand." in CONFIG_FILE_PATH.read_text(encoding="utf-8") @staticmethod - def test_a_setting_removed_by_hand_stays_removed( + def test_a_change_made_from_discord_does_not_refuse_the_next_one( configured: "Callable[[str], Path]", ) -> None: - """Test that a setting deleted by hand is not brought back by a later change.""" - CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + """ + Test that changing a setting does not leave the file looking edited by hand. - TestChangesMadeByHand._edit_by_hand( - CONFIG_FILE_PATH, " full-name: Computer Science Society\n", "" - ) + Each change rewrites the file, so the check has to recognise TeX-Bot's own + writing as its own, or no second change could ever be made. + """ + configured(COMMENTED_CONFIG) config.set_setting("commands:ping:easter-egg-probability", "0.5") + config.set_setting("community-group:full-name", "CompSoc") - assert config.settings.community_group.full_name is None + assert config.settings.community_group.full_name == "CompSoc" @staticmethod - def test_an_edit_made_by_hand_survives_a_removal( + def test_an_unedited_file_is_not_reported_as_changed( configured: "Callable[[str], Path]", ) -> None: - """Test that removing one setting keeps an edit made by hand to another.""" - CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) - - TestChangesMadeByHand._edit_by_hand( - CONFIG_FILE_PATH, - "full-name: Computer Science Society", - "full-name: Edited By Hand", - ) - - config.unset_setting("commands:ping:easter-egg-probability") + """Test that a file nobody has touched is not mistaken for one that was edited.""" + configured(COMMENTED_CONFIG) - assert config.settings.community_group.full_name == "Edited By Hand" - assert ( - config.settings.commands.ping.easter_egg_probability - == DEFAULT_EASTER_EGG_PROBABILITY - ) + assert not config.settings.file_has_changed() @staticmethod - def test_an_edit_made_by_hand_that_is_invalid_blocks_the_change( + def test_differing_line_endings_are_not_mistaken_for_an_edit( configured: "Callable[[str], Path]", ) -> None: """ - Test that a change is refused while the file holds a mistake made by hand. + Test that rewriting the file with other line endings does not count as an edit. - The whole configuration is validated, not merely the setting being changed, so a - mistake elsewhere within the file is reported rather than written back. + A configuration file edited upon one operating system & used upon another is + commonplace, & says nothing about whether its settings were changed. """ CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) - TestChangesMadeByHand._edit_by_hand( - CONFIG_FILE_PATH, "main-guild-id: 1234567890123456789", "main-guild-id: 12" - ) - CONFIG_FILE_CONTENTS_AFTER_EDIT: Final[str] = CONFIG_FILE_PATH.read_text( - encoding="utf-8" + CONFIG_FILE_PATH.write_bytes( + COMMENTED_CONFIG.replace("\n", "\r\n").encode(encoding="utf-8") ) - with pytest.raises(SettingsValidationError, match="main-guild-id"): - config.set_setting("commands:ping:easter-egg-probability", "0.5") - - assert CONFIG_FILE_PATH.read_text(encoding="utf-8") == CONFIG_FILE_CONTENTS_AFTER_EDIT + assert not config.settings.file_has_changed() class TestRemovingSettings: diff --git a/tex-bot-deployment.example.yaml b/tex-bot-deployment.example.yaml index c2ba38b40..9eb42e5c7 100644 --- a/tex-bot-deployment.example.yaml +++ b/tex-bot-deployment.example.yaml @@ -19,6 +19,11 @@ # by hand: `/config get` shows what a setting is currently set to, `/config set` changes # one, and `/config unset` returns one to its default. Each of them is committee-only, # and writes back to this file while keeping the comments you have added to it. +# +# If you edit this file by hand, run `/config reload` before using `/config set` or +# `/config unset`. Both refuse to run against a file that has been edited since TeX-Bot +# last loaded it, so that a change made from within Discord is never quietly mixed +# together with one made here. # Most settings take effect straight away, because they are read at the moment they # are used. A few cannot, and `/config reload` will tell you when a restart is needed: # From 474a6d2208bf1b46da94f1ee26aca1178052d241 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:30:15 +0100 Subject: [PATCH 26/33] Show what an edit made by hand would change a setting to "/config get" already said when the configuration file had been edited since it was last loaded, but not what the edit had changed the setting to, leaving whoever asked to go & read the file to find out. It now shows both the value TeX-Bot is running & the value the file holds, so the difference that reloading would apply can be seen at a glance. An edit that leaves the file invalid is explained rather than shown, since there is no value to show from a file that would be refused, & the reason it would be refused is what needs fixing before reloading. An edit to a secret is reported as differing without either value being rendered. Where the file has been edited but not in a way that changes the setting being viewed, that is said plainly, rather than implying the value shown might be stale when it is not. Also fixes an unhandled exception this uncovered: viewing a setting within an optional section that had been left out of the file raised `KeyError`, because such a section collapses to a single empty value once the settings are flattened & so has no entry of its own. Both settings within the Discord log-channel section are offered by autocomplete, so any deployment without that section would have hit this by choosing one. --- cogs/config.py | 26 ++--- config/__init__.py | 2 + config/_editor.py | 72 ++++++++++++- tests/config/test_editor.py | 195 ++++++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+), 17 deletions(-) diff --git a/cogs/config.py b/cogs/config.py index 607ec3f16..4d51eed74 100644 --- a/cogs/config.py +++ b/cogs/config.py @@ -197,7 +197,9 @@ async def get(self, ctx: "TeXBotApplicationContext", setting_name: str) -> None: setting_metadata: ConfigSettingMetadata try: setting_metadata = config.setting_metadata(setting_name) - CURRENT_VALUE: Final[object] = config.settings.as_flat_mapping()[setting_name] + # NOTE: A setting within a section that has been left out of the file + # entirely has no entry of its own, & is simply unset. + CURRENT_VALUE: Final[object] = config.settings.as_flat_mapping().get(setting_name) except UnknownSettingError as unknown_setting_error: await self._respond_with_unknown_setting(ctx, unknown_setting_error) return @@ -217,9 +219,19 @@ async def get(self, ctx: "TeXBotApplicationContext", setting_name: str) -> None: f"{config.format_setting_value(CURRENT_VALUE, secret=setting_metadata.secret)}" ) - if not IS_SET_WITHIN_FILE: + # NOTE: A setting holding nothing already says so, & saying that it holds its + # default as well would be saying the same thing twice. + if not IS_SET_WITHIN_FILE and CURRENT_VALUE is not None: response_message += " _(default; not set within the configuration file)_" + # NOTE: The value shown above is the one TeX-Bot is running, which is the one it + # loaded. Showing what the file holds alongside it is what stops an edit made + # since then from looking as though it had simply been ignored. + if config.settings.file_has_changed(): + response_message += config.format_file_difference( + setting_name, CURRENT_VALUE, secret=setting_metadata.secret + ) + if setting_metadata.description: response_message += f"\n\n{setting_metadata.description}" @@ -228,16 +240,6 @@ async def get(self, ctx: "TeXBotApplicationContext", setting_name: str) -> None: "\n\n:warning: Changing this setting requires TeX-Bot to be restarted." ) - # NOTE: The value shown is the one currently in use, which is the one that was - # loaded. Saying so is what stops an edit made to the file since then from - # looking as though it had simply been ignored. - if config.settings.file_has_changed(): - response_message += ( - "\n\n:warning: The configuration file has been edited since TeX-Bot " - "last loaded it, so the value shown above may no longer match it. " - "Run `/config reload` to load those edits." - ) - await ctx.respond(response_message, ephemeral=True) @config.command( diff --git a/config/__init__.py b/config/__init__.py index f7f996d4e..ddbb62d39 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -23,6 +23,7 @@ SettingsFileChangedError, UnknownSettingError, documented_setting_names, + format_file_difference, format_setting_value, parse_setting_value, setting_metadata, @@ -52,6 +53,7 @@ "SettingsValidationError", "UnknownSettingError", "documented_setting_names", + "format_file_difference", "format_setting_value", "get_settings_file_path", "get_settings_metadata", diff --git a/config/_editor.py b/config/_editor.py index 333fb1f63..30056a14f 100644 --- a/config/_editor.py +++ b/config/_editor.py @@ -20,8 +20,12 @@ from ruamel.yaml import YAML from ruamel.yaml.error import YAMLError -from ._accessor import SettingsValidationError -from ._document import SettingsDocument +from ._accessor import SettingsValidationError, _flatten_settings +from ._document import ( + InvalidSettingsFileError, + SettingsDocument, + SettingsFileNotFoundError, +) from ._schema import SettingsSchema, get_settings_metadata if TYPE_CHECKING: @@ -36,6 +40,7 @@ "SettingsFileChangedError", "UnknownSettingError", "documented_setting_names", + "format_file_difference", "format_setting_value", "parse_setting_value", "setting_metadata", @@ -177,19 +182,76 @@ def format_setting_value(value: object, *, secret: bool) -> str: return f"`{value}`" -def _validated(document: SettingsDocument) -> SettingsDocument: - """Return the given document, having checked that it holds a valid configuration.""" +def _snapshot_of(document: SettingsDocument) -> SettingsSchema: + """Return the settings held by the given document, refusing it if they are invalid.""" validation_error: ValidationError try: - SettingsSchema.model_validate(document.raw) + return SettingsSchema.model_validate(document.raw) except ValidationError as validation_error: raise SettingsValidationError( document.format_validation_error(validation_error) ) from validation_error + +def _validated(document: SettingsDocument) -> SettingsDocument: + """Return the given document, having checked that it holds a valid configuration.""" + _snapshot_of(document) + return document +def format_file_difference(setting_name: str, running_value: object, *, secret: bool) -> str: + """ + Describe how the configuration file's value for a setting differs from the running one. + + Intended to be shown alongside the value TeX-Bot is running, once the file is known + to have been edited since it was last loaded, so that whoever is looking at a setting + can see what reloading would change it to rather than only being told to reload. + """ + file_error: Exception + try: + FILE_VALUES: Final[Mapping[str, object]] = _flatten_settings( + _snapshot_of(SettingsDocument.load()) + ) + except ( + SettingsValidationError, + SettingsFileNotFoundError, + InvalidSettingsFileError, + OSError, + ) as file_error: + return ( + "\n\n:warning: The configuration file has been edited since TeX-Bot last " + "loaded it, but cannot be loaded as it currently stands:\n" + f"```\n{file_error}\n```" + ) + + # NOTE: A setting within a section that has been left out of the file entirely has no + # entry of its own, & is simply unset. + FILE_VALUE: Final[object] = FILE_VALUES.get(setting_name) + + if running_value == FILE_VALUE: + return ( + "\n\n:information_source: The configuration file has been edited since " + "TeX-Bot last loaded it, though not this setting. " + "Run `/config reload` to apply those edits." + ) + + if secret: + return ( + "\n\n:warning: The configuration file holds a different value for this " + "setting, which TeX-Bot has not loaded. " + "Run `/config reload` to apply that edit." + ) + + return ( + f"\n\n:warning: The configuration file has been edited since TeX-Bot last " + f"loaded it:\n" + f"- currently running: {format_setting_value(running_value, secret=secret)}\n" + f"- within the file: {format_setting_value(FILE_VALUE, secret=secret)}\n" + f"Run `/config reload` to apply that edit." + ) + + def _read_unchanged_file(loaded_document: SettingsDocument | None) -> SettingsDocument: """ Read the configuration file again, refusing it if it no longer matches what is loaded. diff --git a/tests/config/test_editor.py b/tests/config/test_editor.py index 04e7d7cf3..1ff0c2bef 100644 --- a/tests/config/test_editor.py +++ b/tests/config/test_editor.py @@ -20,6 +20,7 @@ DEFAULT_EASTER_EGG_PROBABILITY, MINIMAL_CONFIG, VALID_BOT_TOKEN, + VALID_WEBHOOK_URL, ) if TYPE_CHECKING: @@ -561,6 +562,200 @@ def test_a_section_left_empty_is_still_valid( assert config.reload_settings().changed_settings == set() +class TestDescribingAnEditedFile: + """Test case for showing what an edit made by hand would change a setting to.""" + + @staticmethod + def _edit_by_hand(config_file_path: "Path", original: str, replacement: str) -> None: + """Edit the configuration file directly, as somebody with a text editor would.""" + config_file_path.write_text( + config_file_path.read_text(encoding="utf-8").replace(original, replacement), + encoding="utf-8", + ) + + @staticmethod + def test_the_value_within_the_file_is_shown_alongside_the_running_one( + configured: "Callable[[str], Path]", + ) -> None: + """Test that an edited setting shows both what is running & what the file holds.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + TestDescribingAnEditedFile._edit_by_hand( + CONFIG_FILE_PATH, + "easter-egg-probability: 0.01", + "easter-egg-probability: 0.25", + ) + + DESCRIPTION: Final[str] = config.format_file_difference( + "commands:ping:easter-egg-probability", + config.settings.commands.ping.easter_egg_probability, + secret=False, + ) + + assert "currently running" in DESCRIPTION + assert "`0.01`" in DESCRIPTION + assert "within the file" in DESCRIPTION + assert "`0.25`" in DESCRIPTION + assert "/config reload" in DESCRIPTION + + @staticmethod + def test_a_duration_within_the_file_is_shown_readably( + configured: "Callable[[str], Path]", + ) -> None: + """Test that a duration held by the file is shown the way it is written.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + TestDescribingAnEditedFile._edit_by_hand( + CONFIG_FILE_PATH, + "commands:\n", + "commands:\n strike:\n timeout-duration: 1h30m\n", + ) + + DESCRIPTION: Final[str] = config.format_file_difference( + "commands:strike:timeout-duration", + config.settings.commands.strike.timeout_duration, + secret=False, + ) + + assert "`1d`" in DESCRIPTION + assert "`1h30m`" in DESCRIPTION + + @staticmethod + def test_an_edit_elsewhere_says_this_setting_is_unaffected( + configured: "Callable[[str], Path]", + ) -> None: + """Test that an edit to another setting does not claim this one has changed.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + TestDescribingAnEditedFile._edit_by_hand( + CONFIG_FILE_PATH, + "full-name: Computer Science Society", + "full-name: Edited By Hand", + ) + + DESCRIPTION: Final[str] = config.format_file_difference( + "commands:ping:easter-egg-probability", + config.settings.commands.ping.easter_egg_probability, + secret=False, + ) + + assert "though not this setting" in DESCRIPTION + assert "/config reload" in DESCRIPTION + + @staticmethod + def test_a_secret_within_the_file_is_never_shown( + configured: "Callable[[str], Path]", + ) -> None: + """Test that comparing a secret does not reveal either value.""" + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + EDITED_TOKEN: Final[str] = f"{VALID_BOT_TOKEN[:-4]}wxyz" + + TestDescribingAnEditedFile._edit_by_hand( + CONFIG_FILE_PATH, VALID_BOT_TOKEN, EDITED_TOKEN + ) + + DESCRIPTION: Final[str] = config.format_file_difference( + "discord:bot-token", config.settings.discord.bot_token, secret=True + ) + + assert "different value" in DESCRIPTION + assert EDITED_TOKEN not in DESCRIPTION + assert VALID_BOT_TOKEN not in DESCRIPTION + + @staticmethod + def test_a_file_that_cannot_be_loaded_is_reported( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that an edit leaving the file invalid is explained rather than shown. + + There is no value to show from a file that would be refused, so the reason it + would be refused is shown instead, which is what needs fixing before reloading. + """ + CONFIG_FILE_PATH: Final[Path] = configured(COMMENTED_CONFIG) + + TestDescribingAnEditedFile._edit_by_hand( + CONFIG_FILE_PATH, + "easter-egg-probability: 0.01", + "easter-egg-probability: 9.9", + ) + + DESCRIPTION: Final[str] = config.format_file_difference( + "commands:ping:easter-egg-probability", + config.settings.commands.ping.easter_egg_probability, + secret=False, + ) + + assert "cannot be loaded" in DESCRIPTION + assert "less than or equal to 1" in DESCRIPTION + + @staticmethod + def test_a_setting_added_by_hand_is_shown_against_its_default( + configured: "Callable[[str], Path]", + ) -> None: + """Test that a setting added by hand is compared against the default in use.""" + CONFIG_FILE_PATH: Final[Path] = configured(MINIMAL_CONFIG) + + TestDescribingAnEditedFile._edit_by_hand( + CONFIG_FILE_PATH, + "community-group:\n", + "auto-add-committee-to-threads: false\ncommunity-group:\n", + ) + + DESCRIPTION: Final[str] = config.format_file_difference( + "auto-add-committee-to-threads", + config.settings.auto_add_committee_to_threads, + secret=False, + ) + + assert "`true`" in DESCRIPTION + assert "`false`" in DESCRIPTION + + +class TestSettingsWithinAnOmittedSection: + """Test case for a setting whose whole section has been left out of the file.""" + + @staticmethod + def test_its_value_can_be_read_without_being_written( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that a setting within an omitted section reads as unset, rather than failing. + + Such a setting has no entry of its own once the settings are flattened, because + the section holding it collapses to a single empty value, yet it is still offered + as a setting that can be viewed. + """ + OMITTED_SETTING_NAME: Final[str] = "logging:discord-channel:webhook-url" + + configured(MINIMAL_CONFIG) + + assert OMITTED_SETTING_NAME in config.documented_setting_names() + assert config.settings.as_flat_mapping().get(OMITTED_SETTING_NAME) is None + + @staticmethod + def test_the_file_holding_it_is_compared_without_failing( + configured: "Callable[[str], Path]", + ) -> None: + """Test that adding such a section by hand is described rather than crashed upon.""" + CONFIG_FILE_PATH: Final[Path] = configured(MINIMAL_CONFIG) + + CONFIG_FILE_PATH.write_text( + f"{MINIMAL_CONFIG}" + f"logging:\n" + f" discord-channel:\n" + f" webhook-url: {VALID_WEBHOOK_URL}\n", + encoding="utf-8", + ) + + DESCRIPTION: Final[str] = config.format_file_difference( + "logging:discord-channel:log-level", None, secret=False + ) + + assert "`WARNING`" in DESCRIPTION + + class TestDisplayingValues: """Test case for rendering a setting's value into something readable.""" From c15f81271a1c7a409313b87d37f0bea97b60bc9f Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:47:13 +0100 Subject: [PATCH 27/33] Document configuring TeX-Bot by its deployment configuration file Every piece of documentation still described the environment variables that the deployment configuration file replaced, so anybody following it would have configured a bot that read none of it. README.md now explains the configuration file, what must be filled in before TeX-Bot will start, how to mount it into the container, & how to view & change settings with the `/config` commands. Each error code & repeated task now names the setting responsible rather than the variable that used to hold it, & a table maps every old variable onto the setting that replaced it. CONTRIBUTING.md describes the `config` package a module at a time, in place of the `config.py` it points at, which no longer exists. Its list of cogs was missing eight of them, including the one added for `/config`. Also corrects three things that were wrong rather than merely outdated: * The members-list cookie is named `.AspNet.SharedCookie`, which is what the code sends & reads. Both the schema & the example configuration called it `.ASPXAUTH`, which appears nowhere else in the project, & the schema's wording is shown by `/config get`. * The get-roles reminder interval was documented as defaulting to every 24 hours, where it has always defaulted to every 6 hours. * The example configuration was the only file in the repository failing the yamllint hook, so this branch would have failed CI. Its settings are deliberately ordered to be read from the top down, which the alphabetical ordering that yamllint requires would destroy, so the deployment configuration is now excluded from that check. `.env.example` is deleted, along with the linter that had nothing left to check & the re-inclusion that would have copied a `.env` file into the container image. `python-dotenv` stays a dependency: two database migrations import it, & a migration that has already been applied cannot be edited. --- .dockerignore | 1 - .env.example | 115 --------------------- .gitattributes | 1 - .pre-commit-config.yaml | 6 -- .yamllint.yaml | 8 ++ CONTRIBUTING.md | 47 ++++++++- README.md | 174 ++++++++++++++++++++++++++------ config/_schema.py | 6 +- main.py | 2 +- tex-bot-deployment.example.yaml | 4 +- utils/tex_bot.py | 5 +- 11 files changed, 206 insertions(+), 163 deletions(-) delete mode 100644 .env.example diff --git a/.dockerignore b/.dockerignore index 14db91990..28fb81b84 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,6 @@ CONTRIBUTING.md Dockerfile *.env -!.env tex-bot-deployment.yaml tex-bot-deployment.*.yaml tex-bot-deployment.yaml.*.tmp diff --git a/.env.example b/.env.example deleted file mode 100644 index 54235e593..000000000 --- a/.env.example +++ /dev/null @@ -1,115 +0,0 @@ -# dotenv-linter:off ValueWithoutQuotes - -# !!REQUIRED!! -# The Discord token for the bot you created (available on your bot page in the developer portal: https://discord.com/developers/applications)) -# Must be a valid Discord bot token (see https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts) -DISCORD_BOT_TOKEN=[Replace with your Discord bot token] - -# !!REQUIRED!! -# The ID of the your Discord guild -# Must be a valid Discord guild ID (see https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id) -DISCORD_GUILD_ID=[Replace with the ID of the your Discord guild] - -# The webhook URL of the Discord text channel where error logs should be sent -# Error logs will always be sent to the console, this setting allows them to also be sent to a Discord log channel -# Must be a valid Discord channel webhook URL (see https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) -DISCORD_LOG_CHANNEL_WEBHOOK_URL=[Replace with your Discord log channel webhook URL] - -# The full name of your community group, do NOT use an abbreviation. -# This is substituted into many error/welcome messages sent into your Discord guild, by the bot. -# If this is not set the group-full-name will be retrieved from the name of your group's Discord guild -GROUP_NAME=[Replace with the full name of your community group (not an abbreviation)] - -# The short colloquial name of your community group, it is recommended that you set this to be an abbreviation of your group's name. -# If this is not set the group-short-name will be determined from your group's full name -GROUP_SHORT_NAME=[Replace with the short colloquial name of your community group] - -# The URL of the page where guests can purchase a full membership to join your community group -# Must be a valid URL -PURCHASE_MEMBERSHIP_URL=[Replace with your group\'s purchase-membership URL] - -# The URL of the page containing information about the perks of buying a full membership to join your community group -# Must be a valid URL -MEMBERSHIP_PERKS_URL=[Replace with your group\'s membership-perks URL] - -# The invite link URL to allow users to join your community group's Discord server -# Must be a valid URL -CUSTOM_DISCORD_INVITE_URL=[Replace with your group\'s Discord server invite link] - -# The minimum level that logs must meet in order to be logged to the console output stream -# One of: DEBUG, INFO, WARNING, ERROR, CRITICAL -CONSOLE_LOG_LEVEL=INFO - -# !!REQUIRED!! -# The URL to retrieve the list of IDs of people that have purchased a membership to your community group -# Ensure that all members are visible without pagination. For example, if your members-list is found on the UoB Guild of Students website, ensure the URL includes the "sort by groups" option -# Must be a valid URL -ORGANISATION_ID=[Replace with your group\'s MSL Organisation ID] - -# !!REQUIRED!! -# The cookie required for access to your Student Union's online platform. -# If your group's members-list is stored at a URL that requires authentication, this session cookie should authenticate the bot to view your group's members-list, as if it were logged in to the website as a Committee member -# This can be extracted from your web-browser, after logging in to view your members-list yourself. It will probably be listed as a cookie named `.AspNet.SharedCookie` -SU_PLATFORM_ACCESS_COOKIE=[Replace with your .AspNet.SharedCookie cookie] - -# The probability that the more rare ping command response will be sent instead of the normal one -# Must be a float between & including 0 to 1 -PING_COMMAND_EASTER_EGG_PROBABILITY=0.01 - -# The path to the messages JSON file that contains the common messages sent by the bot -# Must be a path to a JSON file that exists, that contains a JSON string that can be decoded into a Python dict object -MESSAGES_FILE_PATH=messages.json - -# Whether introduction reminders will be sent to Discord members that are not inducted, saying that they need to send an introduction to be allowed access -# One of: Once, Interval, False -SEND_INTRODUCTION_REMINDERS=Once - -# How long to wait after a user joins your guild before sending them the first/only message remind them to send an introduction -# Is ignored if SEND_INTRODUCTION_REMINDERS=False -# Must be a string of the seconds, minutes, hours, days or weeks before the first/only reminder is sent (format: "smhdw") -# The delay must be longer than or equal to 1 day (in any allowed format) -SEND_INTRODUCTION_REMINDERS_DELAY=40h - -# The interval of time between sending out reminders to Discord members that are not inducted, saying that they need to send an introduction to be allowed access -# Is ignored if SEND_INTRODUCTION_REMINDERS=Once or SEND_INTRODUCTION_REMINDERS=False -# Must be a string of the seconds, minutes or hours between reminders (format: "smh") -SEND_INTRODUCTION_REMINDERS_INTERVAL=6h - -# Whether reminders will be sent to Discord members that have been inducted, saying that they can get opt-in roles. (This message will be only sent once per Discord member) -# Must be a boolean (True or False) -SEND_GET_ROLES_REMINDERS=True - -# How long to wait after a user is inducted before sending them the message to get some opt-in roles -# Is ignored if SEND_GET_ROLES_REMINDERS=False -# Must be a string of the seconds, minutes, hours, days or weeks before a reminder is sent (format: "smhdw") -# The delay must be longer than or equal to 1 day (in any allowed format) -SEND_GET_ROLES_REMINDERS_DELAY=40h - -# !!This is an advanced configuration variable, so is unlikely to need to be changed from its default value!! -# The interval of time between sending out reminders to Discord members that have been inducted, saying that they can get opt-in roles. (This message will be only sent once, the interval is just how often the check for new guests occurs) -# Is ignored if SEND_GET_ROLES_REMINDERS=False -# Must be a string of the seconds, minutes or hours between reminders (format: "smh") -ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL=24h - -# The number of days to look over messages sent, to generate statistics data -# Must be a float representing the number of days to look back through -STATISTICS_DAYS=30 - -# The names of the roles to gather statistics about, to display in bar chart graphs -# Must be a comma seperated list of strings of role names -STATISTICS_ROLES=Committee,Committee-Elect,Student Rep,Member,Guest,Server Booster,Foundation Year,First Year,Second Year,Final Year,Year In Industry,Year Abroad,PGT,PGR,Alumnus/Alumna,Postdoc,Quiz Victor - -# !!REQUIRED!! -# The URL of the your group's Discord guild moderation document -# Must be a valid URL -MODERATION_DOCUMENT_URL=[Replace with your group\'s moderation document URL] - -# The name of the channel, that warning messages will be sent to when a committee-member manually applies a moderation action (instead of using the `/strike` command) -# Must be the name of a Discord channel in your group's Discord guild, or the value "DM" (which indicates that the messages will be sent in the committee-member's DMs) -# This can be the name of ANY Discord channel (so the offending person *will* be able to see these messages if a public channel is chosen) -MANUAL_MODERATION_WARNING_MESSAGE_LOCATION=DM - -# The set of roles that are tied to the membership of your community group -# These roles will be removed along with the membership role upon annual handover/reset -# Must be a comma seperated list of strings of role names -MEMBERSHIP_DEPENDENT_ROLES=member-red,member-blue,member-green,member-yellow,member-purple,member-pink,member-orange,member-grey,member-black,member-white diff --git a/.gitattributes b/.gitattributes index a1401149c..b34f90c00 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,7 +7,6 @@ .toml text eol=lf .md text eol=lf .*ignore text eol=lf -.env* text eol=lf .gitattributes text eol=lf .gitlint text eol=lf .python-version text eol=lf diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9efceea94..cf92e6fab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,12 +45,6 @@ repos: - id: zizmor args: [--no-progress, --fix, --persona=auditor] - - repo: https://github.com/dotenv-linter/dotenv-linter - rev: v4.0.0 - hooks: - - id: dotenv-linter - args: [fix, --no-backup] - - repo: https://github.com/hadolint/hadolint rev: v2.14.0 hooks: diff --git a/.yamllint.yaml b/.yamllint.yaml index cd13ce8a8..c5957e39b 100644 --- a/.yamllint.yaml +++ b/.yamllint.yaml @@ -1,5 +1,13 @@ extends: default +# NOTE: The deployment configuration is written by whoever deploys TeX-Bot, rather than +# being one of this repository's own files. Its settings are ordered to be read from the +# top down (those that must be filled in first, then each section in turn), which the +# alphabetical key ordering required below would destroy. +ignore: | + tex-bot-deployment.*.yaml + tex-bot-deployment.yaml + locale: en_GB.UTF-8 rules: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30a6d14da..370696bec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,16 +52,45 @@ If you are submitting a feature request, please include the steps to implement t ### Top level files * [`main.py`](main.py): is the main entrypoint to instantiate the [`Bot` object](https://docs.pycord.dev/stable/api/clients.html#discord.Bot) & run it -* [`config.py`](config.py): retrieves the [environment variables](README.md#setting-environment-variables) & populates the correct values into the `settings` object +* [`tex-bot-deployment.example.yaml`](tex-bot-deployment.example.yaml): documents every [configuration setting](README.md#configuring-tex-bot) that TeX-Bot understands, along with its default value ### Other significant directories +* [`config/`](config): reads & validates the deployment configuration file, and exposes it as the `settings` object, see [below](#configuration) for more information * [`cogs/`](cogs): contains all the [cogs](https://guide.pycord.dev/popular-topics/cogs) within this project, see [below](#cogs) for more information * [`exceptions/`](exceptions): contains common [exception](https://docs.python.org/3/tutorial/errors) subclasses that may be raised when certain errors occur * [`utils/`](utils): contains common utility classes & functions used by the top-level modules & cogs * [`db/core/models/`](db/core/models): contains all the [database ORM models](https://docs.djangoproject.com/en/stable/topics/db/models) to interact with storing information longer-term (between individual command events) +* [`stubs/`](stubs): contains hand-written [type stubs](https://typing.python.org/en/latest/spec/distributing.html#stub-files) for the third-party packages that do not ship their own * [`tests/`](tests): contains the complete test suite for this project, based on the [Pytest framework](https://pytest.org) +### Configuration + +TeX-Bot is configured by a single [YAML](https://yaml.org) file, which is read, validated & applied by the modules within [the `config` package](config). +Each module owns one concern, so that no other part of the project needs to know how the configuration file is stored: + +* [`config/_schema.py`](config/_schema.py): the single source of truth for the configuration. +Declares every setting as a [Pydantic](https://docs.pydantic.dev) model: its type, its constraints, its default, its help text, whether it holds a secret & whether changing it requires a restart. +**Any new setting is added here, and nowhere else** + +* [`config/_document.py`](config/_document.py): owns every interaction with the file itself; locating it, parsing it while retaining its comments & formatting, writing it back atomically, & reporting a validation failure against the line that caused it + +* [`config/_accessor.py`](config/_accessor.py): holds the loaded configuration as a single immutable snapshot, replaced wholesale whenever it is reloaded, so that no reader can observe a half-applied configuration + +* [`config/_editor.py`](config/_editor.py): changes an individual setting, on behalf of [the `/config` command](README.md#changing-settings-from-within-discord), validating the result before anything is written + +* [`config/_logging.py`](config/_logging.py): applies the logging settings, replacing existing handlers rather than adding to them, so that reloading cannot accumulate duplicates + +* [`config/_messages.py`](config/_messages.py): loads [the messages file](messages.json), which is held separately from the deployment configuration because it is a body of content rather than a set of settings + +Settings are read as attributes of the `settings` object, by the section holding them: + +```python +import config + +config.settings.commands.ping.easter_egg_probability +``` + ### Cogs [Cogs](https://guide.pycord.dev/popular-topics/cogs) are attachable modules that are loaded onto the [`Bot` instance](https://docs.pycord.dev/en/stable/api/clients.html#discord.Bot). @@ -73,18 +102,34 @@ There are separate cog files for each activity, and one [`__init__.py`](cogs/__i * [`cogs/__init__.py`](cogs/__init__.py): instantiates all the cog classes within this directory +* [`cogs/add_users_to_threads_and_channels.py`](cogs/add_users_to_threads_and_channels.py): cogs for adding Discord members & roles to a channel or thread + +* [`cogs/annual_handover_and_reset.py`](cogs/annual_handover_and_reset.py): cogs for performing your group's annual committee handover & membership reset + * [`cogs/archive.py`](cogs/archive.py): cogs for archiving categories of channels within your group's Discord guild +* [`cogs/check_su_platform_authorisation.py`](cogs/check_su_platform_authorisation.py): cogs for checking whether TeX-Bot is still authenticated to your group's SU platform + * [`cogs/command_error.py`](cogs/command_error.py): cogs for sending error messages when commands fail to complete/execute +* [`cogs/committee_actions_tracking.py`](cogs/committee_actions_tracking.py): cogs for tracking the actions assigned to each committee member + +* [`cogs/config.py`](cogs/config.py): cogs for viewing & changing [TeX-Bot's configuration](README.md#changing-settings-from-within-discord) at run-time + * [`cogs/delete_all.py`](cogs/delete_all.py): cogs for deleting all permanent data stored in a specific object's table in the database * [`cogs/edit_message.py`](cogs/edit_message.py): cogs for editing messages that were previously sent by TeX-Bot +* [`cogs/everest.py`](cogs/everest.py): cogs for calculating how many steps of Mount Everest a university assignment is worth + * [`cogs/induct.py`](cogs/induct.py): cogs for inducting people into your group's Discord guild +* [`cogs/invite_link.py`](cogs/invite_link.py): cogs for sending an invite link to your group's Discord guild + * [`cogs/kill.py`](cogs/kill.py): cogs related to the shutdown of TeX-Bot +* [`cogs/make_applicant.py`](cogs/make_applicant.py): cogs related to making guests into committee applicants + * [`cogs/make_member.py`](cogs/make_member.py): cogs related to making guests into members * [`cogs/ping.py`](cogs/ping.py): cog to request a [ping](https://wikipedia.org/wiki/Ping-pong_scheme#Internet) response diff --git a/README.md b/README.md index 2183c5320..63a0122ee 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Members of your [Discord guild](https://discord.com/developers/docs/resources/gu If a user encounters any of these errors, please communicate the error to the committee member that has been assigned to upkeep & deployment of your instance of TeX-Bot. The meaning of each error code is given here: -* `E1011` - The value for the [environment variable](https://wikipedia.org/wiki/Environment_variable) `DISCORD_GUILD_ID` is an [ID](https://discord.com/developers/docs/reference#snowflakes) that references a [Discord guild](https://discord.com/developers/docs/resources/guild) that does not exist +* `E1011` - The value of the `discord:main-guild-id` [configuration setting](#configuring-tex-bot) is an [ID](https://discord.com/developers/docs/reference#snowflakes) that references a [Discord guild](https://discord.com/developers/docs/resources/guild) that does not exist * `E1021` - Your [Discord guild](https://discord.com/developers/docs/resources/guild) does not contain a [role](https://discord.com/developers/docs/topics/permissions#role-object) with the name "@**Committee**". (This [role](https://discord.com/developers/docs/topics/permissions#role-object) is required for the `/write-roles`, `/edit-message`, `/induct`, `/strike`, `/archive`, `/kill`, `/delete-all` & `/ensure-members-inducted` [commands](https://discord.com/developers/docs/interactions/application-commands)) @@ -76,7 +76,7 @@ The meaning of each error code is given here: (This [text channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel) is required for the `/induct` [command](https://discord.com/developers/docs/interactions/application-commands)) * `E1041` - The community group member IDs could not be retrieved from the SU platform. -(It is likely that your `SU_PLATFORM_ACCESS_COOKIE` is invalid. +(It is likely that your `community-group:msl:auth-cookie` [configuration setting](#configuring-tex-bot) is invalid. If your community group is a [Guild of Students](https://guildofstudents.com) [society](https://wikipedia.org/wiki/Student_society), the community group member IDs will be a list of [UoB IDs](https://intranet.birmingham.ac.uk/campus-services/id-cards.aspx)) * `E1042` - The reference to the `@everyone` [role](https://discord.com/developers/docs/topics/permissions#role-object) could not be correctly retrieved. @@ -103,18 +103,23 @@ This may require some changes to the deployment configuration or [an issue about The problem that caused the error should be addressed *immediately*, or otherwise TeX-Bot should be manually shut down to prevent further errors * `CRITICAL` - An **unrecoverable error occurred**. -This level of error will cause TeX-Bot to shut down, as the problem can only be solved by fixing one or more of the [configuration environment variables](https://wikipedia.org/wiki/Environment_variable) +This level of error will cause TeX-Bot to shut down, as the problem can only be solved by fixing one or more of the settings within [your deployment configuration file](#configuring-tex-bot) ## [Repeated Tasks](https://docs.pycord.dev/en/stable/ext/tasks) Conditions -The [configuration variables](https://wikipedia.org/wiki/Environment_variable) `SEND_INTRODUCTION_REMINDERS` & `SEND_GET_ROLES_REMINDERS` determine whether their related [tasks](https://docs.pycord.dev/en/stable/ext/tasks) should run. +The `reminders:send-introduction-reminders:enabled` & `reminders:send-get-roles-reminders:enabled` [configuration settings](#configuring-tex-bot) determine whether their related [tasks](https://docs.pycord.dev/en/stable/ext/tasks) should run. However, because these are rather annoying/drastic actions to be executed automatically, there are additional conditions that must be met on a per-[member](https://discord.com/developers/docs/resources/guild#guild-member-object) basis for the action to trigger. -The conditions for each [task](https://docs.pycord.dev/en/stable/ext/tasks) are listed below, along with the additional [environment variables](https://wikipedia.org/wiki/Environment_variable) that can be used to configure the conditions to suit your needs. +The conditions for each [task](https://docs.pycord.dev/en/stable/ext/tasks) are listed below, along with the additional settings that can be used to configure the conditions to suit your needs. + +> [!IMPORTANT] +> Whether each task is enabled, and the interval it runs at, are fixed when the task is created as TeX-Bot starts up. +> Changing either of them requires TeX-Bot to be restarted; `/config reload` will tell you so. +> The `delay` settings are read each time they are used, so they take effect immediately. | Task Name | Enable/Disable | Per-Member Conditions | Scheduled Interval | |-------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `introduction_reminder` | `SEND_INTRODUCTION_REMINDERS`:
* `Once` - Only send the introduction reminder once (even if they later delete the message)
* `Interval` - Send an introduction reminder at a set interval
* `False` - Do not send introduction reminders | * The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) has not been inducted (does not have the "@**Guest**" [role](https://discord.com/developers/docs/topics/permissions#role-object))
* The time since the [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) joined your community's guild is greater than `SEND_INTRODUCTION_REMINDERS_DELAY`
* The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) has not opted out of introduction reminders
* The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) has not yet been sent an introduction reminder. (Only applies when `SEND_INTRODUCTION_REMINDERS` is set to the value `Once`) | The interval of time between this task running is determined by `SEND_INTRODUCTION_REMINDERS_INTERVAL`. (When `SEND_INTRODUCTION_REMINDERS` is set to the value `Once`, all [Discord members](https://discord.com/developers/docs/resources/guild#guild-member-object) will still be checked at this interval, just not sent a message if they have already been sent an introduction reminder). The default interval is to send messages every 6 hours | -| `get_roles_reminder` | `SEND_GET_ROLES_REMINDERS`:
* `True` - A single reminder for the [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) to get [roles](https://discord.com/developers/docs/topics/permissions#role-object) will be sent to them only once (even if they later delete the message)
* `False` - Do not send any reminders for [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) to get [roles](https://discord.com/developers/docs/topics/permissions#role-object) | * The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) has been inducted (has the "@**Guest**" [role](https://discord.com/developers/docs/topics/permissions#role-object))
* The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) does not have any of the opt-in [roles](https://discord.com/developers/docs/topics/permissions#role-object). (E.g. "@**First Year**" or "@**Anime**".) (Having the green "@**Member**" [role](https://discord.com/developers/docs/topics/permissions#role-object) or even the "@**Committee**" [role](https://discord.com/developers/docs/topics/permissions#role-object) makes no difference)
* The time since the [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) was inducted (gained the "@**Guest**" [role](https://discord.com/developers/docs/topics/permissions#role-object)) is greater than `SEND_GET_ROLES_REMINDERS_DELAY`
* The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) has not yet been sent a reminder to get [roles](https://discord.com/developers/docs/topics/permissions#role-object) | The interval of time between this task running is determined by `ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL`. It is unlikely that this value will need to be changed from the default of 24 hours | +| `introduction_reminder` | `reminders:send-introduction-reminders:enabled`:
* `once` - Only send the introduction reminder once (even if they later delete the message)
* `interval` - Send an introduction reminder at a set interval
* `false` - Do not send introduction reminders | * The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) has not been inducted (does not have the "@**Guest**" [role](https://discord.com/developers/docs/topics/permissions#role-object))
* The time since the [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) joined your community's guild is greater than `reminders:send-introduction-reminders:delay`
* The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) has not opted out of introduction reminders
* The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) has not yet been sent an introduction reminder. (Only applies when `reminders:send-introduction-reminders:enabled` is set to the value `once`) | The interval of time between this task running is determined by `reminders:send-introduction-reminders:interval`. (When `reminders:send-introduction-reminders:enabled` is set to the value `once`, all [Discord members](https://discord.com/developers/docs/resources/guild#guild-member-object) will still be checked at this interval, just not sent a message if they have already been sent an introduction reminder). The default interval is to send messages every 6 hours | +| `get_roles_reminder` | `reminders:send-get-roles-reminders:enabled`:
* `true` - A single reminder for the [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) to get [roles](https://discord.com/developers/docs/topics/permissions#role-object) will be sent to them only once (even if they later delete the message)
* `false` - Do not send any reminders for [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) to get [roles](https://discord.com/developers/docs/topics/permissions#role-object) | * The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) has been inducted (has the "@**Guest**" [role](https://discord.com/developers/docs/topics/permissions#role-object))
* The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) does not have any of the opt-in [roles](https://discord.com/developers/docs/topics/permissions#role-object). (E.g. "@**First Year**" or "@**Anime**".) (Having the green "@**Member**" [role](https://discord.com/developers/docs/topics/permissions#role-object) or even the "@**Committee**" [role](https://discord.com/developers/docs/topics/permissions#role-object) makes no difference)
* The time since the [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) was inducted (gained the "@**Guest**" [role](https://discord.com/developers/docs/topics/permissions#role-object)) is greater than `reminders:send-get-roles-reminders:delay`
* The [Discord member](https://discord.com/developers/docs/resources/guild#guild-member-object) has not yet been sent a reminder to get [roles](https://discord.com/developers/docs/topics/permissions#role-object) | The interval of time between this task running is determined by `reminders:send-get-roles-reminders:interval`. It is unlikely that this value will need to be changed from the default of every 6 hours | ## Deploying in Production @@ -123,9 +128,30 @@ It is can be pulled from the [GitHub Container Registry](https://docs.github.com (An introduction on how to use a [docker-compose deployment](https://docs.docker.com/compose) can be found [here](https://docs.docker.com/get-started/08_using_compose).) See [**Versioning**](#versioning) for the full list of available version tags for each release. -Before running the [container](https://docs.docker.com/resources/what-container), some [environment variables](https://wikipedia.org/wiki/Environment_variable) will need to be set. -These can be defined in your [`compose.yaml`](https://docs.docker.com/compose/compose-application-model#the-compose-file) file. -The required [environment variables](https://wikipedia.org/wiki/Environment_variable) are explained within [the "Setting Environment Variables" section](#setting-environment-variables). +Before running the [container](https://docs.docker.com/resources/what-container), you will need to create a deployment configuration file. +This is explained within [the "Configuring TeX-Bot" section](#configuring-tex-bot). + +The container reads its configuration from `/app/data/tex-bot-deployment.yaml`, so mount the **directory** holding your configuration file at `/app/data`: + +```yaml +services: + tex-bot: + image: ghcr.io/cssuob/tex-bot-py-v2:latest + volumes: + - ./tex-bot-data:/app/data +``` + +> [!IMPORTANT] +> Mount the directory, rather than the configuration file itself. +> TeX-Bot rewrites the file in place whenever [the `/config` command](#changing-settings-from-within-discord) changes a setting, and an individually mounted file cannot be replaced. + +The container runs as the non-root user with UID & GID `999`, which must be able to read and write your configuration file: + +```shell +chown -R 999:999 ./tex-bot-data +``` + +To keep your configuration somewhere else within the container, set the `TEX_BOT_CONFIG_PATH` [environment variable](https://wikipedia.org/wiki/Environment_variable) to the full path of the file. ## Local Deployment @@ -151,36 +177,122 @@ It's also handy if you have an empty [Discord guild](https://discord.com/develop The correct [invite URL](https://docs.pycord.dev/en/stable/discord.html#inviting-your-bot) will be displayed to you in the console the first time you run the bot (or if you set a high verbosity log level) -### Setting [Environment Variables](https://wikipedia.org/wiki/Environment_variable) +### Configuring TeX-Bot -You'll also need to set a number of [environment variables](https://wikipedia.org/wiki/Environment_variable) before running TeX-Bot: +TeX-Bot is configured by a single [YAML](https://yaml.org) file, `tex-bot-deployment.yaml`. +Copy [the example file](tex-bot-deployment.example.yaml) to create your own: -* `DISCORD_BOT_TOKEN`: The [Discord bot secret token](https://itexus.com/glossary/discord-bot-token) for the [instance of TeX-Bot](https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts) you created. - * The [Discord bot token](https://itexus.com/glossary/discord-bot-token) is available on [your bot's page in the Discord Developer Portal](https://discord.com/developers/applications). +```shell +cp tex-bot-deployment.example.yaml tex-bot-deployment.yaml +``` -* `DISCORD_GUILD_ID`: The [ID](https://discord.com/developers/docs/reference#snowflakes) of your community group's [Discord guild](https://discord.com/developers/docs/resources/guild). +By default, this file is read from the repository root. +Set the `TEX_BOT_CONFIG_PATH` [environment variable](https://wikipedia.org/wiki/Environment_variable) to keep it anywhere else. -* `DISCORD_LOG_CHANNEL_WEBHOOK_URL`: The [webhook URL](https://support.discord.com/hc/articles/228383668-Intro-to-Webhooks) of the [Discord text channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel) where error log messages should be sent. - * This setting is optional. - Error logs will **always** be sent to the [console](https://wikipedia.org/wiki/Terminal_emulator), this setting just allows them to also be sent to a [Discord log channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel). +> [!CAUTION] +> Your configuration file holds your [Discord bot token](https://itexus.com/glossary/discord-bot-token) and your SU platform [session cookie](https://wikipedia.org/wiki/HTTP_cookie#Session_cookie). +> Anybody holding either can act as your bot, or read your group's members list. +> It is excluded by [`.gitignore`](.gitignore), so take care not to commit it or share it. -* `ORGANISATION_ID`: Your SU platform organisation ID. This is used to dynamically create the members list and other needed URLs. +Only two settings must be filled in before TeX-Bot will start: -* `SU_PLATFORM_ACCESS_COOKIE`: The SU platform [access session cookie](https://wikipedia.org/wiki/HTTP_cookie#Session_cookie). - * This [session cookie](https://wikipedia.org/wiki/HTTP_cookie#Session_cookie) will [authenticate](https://wikipedia.org/wiki/Authentication) TeX-Bot to view your group's members list on the SU platform, as if it were [logged in to the website](https://wikipedia.org/wiki/Login_session) as a Committee member. - * This can be [extracted from your web-browser](https://wikihow.com/View-Cookies), after logging in to view your members list yourself. - It will most likely be listed as a [cookie](https://wikipedia.org/wiki/HTTP_cookie) named `.AspNet.SharedCookie`. - * If you wish to test TeX-Bot with the SU platform-access disabled, a dummy value of 128 `0` characters can be used. - Note that this will cause many commands and scheduled tasks to fail when they are used at runtime. +* `discord:bot-token`: The [Discord bot secret token](https://itexus.com/glossary/discord-bot-token) for the [instance of TeX-Bot](https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts) you created. + * This is available on [your bot's page in the Discord Developer Portal](https://discord.com/developers/applications). -You can put these [variables](https://wikipedia.org/wiki/Environment_variable) in a [`.env` file](https://blog.bitsrc.io/a-gentle-introduction-to-env-files-9ad424cc5ff4) in the root folder, as [python-dotenv](https://saurabh-kumar.com/python-dotenv) is used to collect all [environment variables](https://wikipedia.org/wiki/Environment_variable). -There is an [`.env.example` file](.env.example) in the repo that you can rename and populate. +* `discord:main-guild-id`: The [ID](https://discord.com/developers/docs/reference#snowflakes) of your community group's [Discord guild](https://discord.com/developers/docs/resources/guild). -There are also many other configuration settings that can be changed to alter the behaviour of TeX-Bot. -These are all listed in [the `.env.example` file](.env.example), along with the behaviours that will be affected. +Every other setting is optional, and its default is shown in [the example file](tex-bot-deployment.example.yaml) alongside an explanation of what it affects. +Some of the more commonly changed ones are: -Any [variables](https://wikipedia.org/wiki/Environment_variable), in [the `.env.example` file](.env.example), marked with `# !!REQUIRED!!` must be set before running TeX-Bot. -All other [variables](https://wikipedia.org/wiki/Environment_variable) are optional and their default values are shown as the example value for each variable in [the `.env.example` file](.env.example). +* `logging:discord-channel:webhook-url`: The [webhook URL](https://support.discord.com/hc/articles/228383668-Intro-to-Webhooks) of the [Discord text channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel) where error log messages should be sent. + * Error logs will **always** be sent to the [console](https://wikipedia.org/wiki/Terminal_emulator); this setting just allows them to also be sent to a [Discord log channel](https://docs.pycord.dev/en/stable/api/models.html#discord.TextChannel). + * Omit the whole `logging:discord-channel` section to disable this. + +* `community-group:msl:organisation-id`: Your SU platform organisation ID, used to build your members list & other URLs. + +* `community-group:msl:auth-cookie`: The SU platform [access session cookie](https://wikipedia.org/wiki/HTTP_cookie#Session_cookie). + * This [session cookie](https://wikipedia.org/wiki/HTTP_cookie#Session_cookie) will [authenticate](https://wikipedia.org/wiki/Authentication) TeX-Bot to view your group's members list on the SU platform, as if it were [logged in to the website](https://wikipedia.org/wiki/Login_session) as a Committee member. + * This can be [extracted from your web-browser](https://wikihow.com/View-Cookies), after logging in to view your members list yourself. + It will most likely be listed as a [cookie](https://wikipedia.org/wiki/HTTP_cookie) named `.AspNet.SharedCookie`. + * Leaving this unset disables SU platform access. + Note that this will cause many commands & scheduled tasks to fail when they are used at runtime. + +> [!NOTE] +> Settings are written in kebab-case & nested into sections. +> Throughout this documentation a setting is named by the path to it, separated by colons: `community-group:msl:auth-cookie` refers to `auth-cookie`, within `msl`, within `community-group`. +> +> Lengths of time are written largest-unit-first, in the format `dhms`, so `1h30m` & `2d` are both valid. +> Every part must carry its unit, so a bare `24` is rejected rather than being read as 24 seconds. + +An invalid configuration file is never applied. +TeX-Bot reports the line responsible & keeps running on the last configuration that loaded successfully. + +#### Changing Settings From Within Discord + +Configuration can also be viewed & changed from within [Discord](https://discord.com), without editing the file by hand. +All of these [commands](https://discord.com/developers/docs/interactions/application-commands) require the "@**Committee**" [role](https://discord.com/developers/docs/topics/permissions#role-object), and reply only to the person that ran them: + +* `/config get `: Shows what a setting is currently set to, & what it affects +* `/config set `: Changes a single setting, then applies it +* `/config unset `: Returns a single setting to its default value +* `/config reload`: Reads the configuration file again, applying any changes made to it + +Changing a setting rewrites your configuration file, keeping the comments & formatting you have added to it. + +> [!IMPORTANT] +> If you edit the configuration file by hand, run `/config reload` before using `/config set` or `/config unset`. +> Both refuse to run against a file that has been edited since TeX-Bot last loaded it, so that a change made from within Discord is never quietly mixed together with one made by hand. + +Most settings take effect as soon as they are applied, because they are read at the moment they are used. +A few are fixed while TeX-Bot is starting up & cannot be changed without restarting it; `/config reload` will tell you when one of those has changed. + +#### Other [Environment Variables](https://wikipedia.org/wiki/Environment_variable) + +Only two [environment variables](https://wikipedia.org/wiki/Environment_variable) are used, & both are optional: + +* `TEX_BOT_CONFIG_PATH`: The location of your deployment configuration file. + Defaults to `tex-bot-deployment.yaml` within the repository root +* `MESSAGES_FILE_PATH`: The location of [the messages file](messages.json), holding the welcome & roles messages TeX-Bot sends. + Defaults to `messages.json` within the repository root + +#### Migrating From an Older Version + +Earlier versions of TeX-Bot were configured by [environment variables](https://wikipedia.org/wiki/Environment_variable), usually held in a `.env` file. +Those are no longer read at all, & a deployment still using them will start with only its default settings. + +Move each value into your `tex-bot-deployment.yaml`, using the table below. +Where an old variable is not listed, [the example configuration file](tex-bot-deployment.example.yaml) names & explains every setting that exists. + +| Old environment variable | Configuration setting | +|----------------------------------------------|----------------------------------------------------------| +| `DISCORD_BOT_TOKEN` | `discord:bot-token` | +| `DISCORD_GUILD_ID` | `discord:main-guild-id` | +| `DISCORD_LOG_CHANNEL_WEBHOOK_URL` | `logging:discord-channel:webhook-url` | +| `CONSOLE_LOG_LEVEL` | `logging:console:log-level` | +| `GROUP_NAME` | `community-group:full-name` | +| `GROUP_SHORT_NAME` | `community-group:short-name` | +| `PURCHASE_MEMBERSHIP_URL` | `community-group:links:purchase-membership` | +| `MEMBERSHIP_PERKS_URL` | `community-group:links:membership-perks` | +| `MODERATION_DOCUMENT_URL` | `community-group:links:moderation-policy` | +| `CUSTOM_DISCORD_INVITE_URL` | `community-group:links:custom-discord-invite-link` | +| `MEMBERSHIP_DEPENDENT_ROLES` | `community-group:membership-dependent-roles` | +| `ORGANISATION_ID` | `community-group:msl:organisation-id` | +| `SU_PLATFORM_ACCESS_COOKIE` | `community-group:msl:auth-cookie` | +| `PING_COMMAND_EASTER_EGG_PROBABILITY` | `commands:ping:easter-egg-probability` | +| `STATISTICS_DAYS` | `commands:stats:lookback-days` | +| `STATISTICS_ROLES` | `commands:stats:displayed-roles` | +| `MANUAL_MODERATION_WARNING_MESSAGE_LOCATION` | `commands:strike:performed-manually-warning-location` | +| `SEND_INTRODUCTION_REMINDERS` | `reminders:send-introduction-reminders:enabled` | +| `SEND_INTRODUCTION_REMINDERS_DELAY` | `reminders:send-introduction-reminders:delay` | +| `SEND_INTRODUCTION_REMINDERS_INTERVAL` | `reminders:send-introduction-reminders:interval` | +| `SEND_GET_ROLES_REMINDERS` | `reminders:send-get-roles-reminders:enabled` | +| `SEND_GET_ROLES_REMINDERS_DELAY` | `reminders:send-get-roles-reminders:delay` | +| `ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL` | `reminders:send-get-roles-reminders:interval` | + +Two differences are worth noting while migrating: + +* Lengths of time are now written largest-unit-first (`1h30m`, not `30m1h`), & a delay is no longer required to be at least one day +* A comma-separated list, such as `STATISTICS_ROLES`, is now written as a [YAML list](https://yaml.org/spec/1.2.2/#collections) ### Running The Bot diff --git a/config/_schema.py b/config/_schema.py index 816f3ac52..6fff8d335 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -450,9 +450,9 @@ class MSLSettings(_BaseSettingsSchema): # type: ignore[explicit-any] "The MSL authentication session cookie.\n" "This should authenticate TeX-Bot to view your group's members-list, " "as if it were logged in to the website as a committee member.\n" - "If your members-list is found on the UoB Guild of Students website, " - "this can be extracted from your web-browser after manually logging in: " - "it will probably be listed as a cookie named `.ASPXAUTH`." + "This can be extracted from your web-browser after manually logging in " + "to view your members-list yourself: " + "it will probably be listed as a cookie named `.AspNet.SharedCookie`." ), json_schema_extra={"requires_restart": False, "secret": True}, ) diff --git a/main.py b/main.py index 2f5f1dd50..94669e425 100755 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ """ The main entrypoint into the running of TeX-Bot. -It loads the settings values from the .env file/the environment variables, +It loads the settings values from the deployment configuration file, then ensures the Django database is correctly migrated to the latest version and finally begins the asynchronous running process for TeX-Bot. """ diff --git a/tex-bot-deployment.example.yaml b/tex-bot-deployment.example.yaml index 9eb42e5c7..826e17579 100644 --- a/tex-bot-deployment.example.yaml +++ b/tex-bot-deployment.example.yaml @@ -68,8 +68,8 @@ community-group: msl: # Optional. Your community group's organisation ID on your MSL website. # organisation-id: "1234" - # Optional. Your members-list authentication session cookie. On the UoB Guild of - # Students website this is the cookie named `.ASPXAUTH`. + # Optional. Your members-list authentication session cookie, extracted from your + # web-browser after logging in. It is probably named `.AspNet.SharedCookie`. # auth-cookie: your-authentication-cookie-value auto-cookie-checking: enabled: false diff --git a/utils/tex_bot.py b/utils/tex_bot.py index 1a9c46594..8e445dad9 100644 --- a/utils/tex_bot.py +++ b/utils/tex_bot.py @@ -286,8 +286,9 @@ def group_full_name(self) -> str: This is substituted into many error/welcome messages sent into your Discord guild, by TeX-Bot. - The group-full-name is either retrieved from the provided environment variable - or automatically identified from the name of your group's Discord guild. + The group-full-name is either retrieved from the `community-group:full-name` + configuration setting, or automatically identified from the name of your group's + Discord guild. """ return settings.community_group.full_name or ( "The Computer Science Society" From 1ce8255ac96c6666da5493c0c438a0e2cbce6ed4 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:12:09 +0100 Subject: [PATCH 28/33] Bug fixes --- cogs/config.py | 116 ++++++++++++++++++++++------- cogs/ping.py | 4 +- cogs/startup.py | 49 +++++------- cogs/strike.py | 16 +++- config/_accessor.py | 34 +++++++-- config/_document.py | 41 +++++++++- config/_editor.py | 22 +++++- config/_schema.py | 57 ++++++++++---- exceptions/__init__.py | 2 - exceptions/config_changes.py | 30 -------- stubs/discord/commands/options.pyi | 24 ++---- tests/config/conftest.py | 41 +++++++++- tests/config/test_accessor.py | 37 +++++++-- tests/config/test_command.py | 29 ++++++++ tests/config/test_document.py | 31 +++++++- tests/config/test_editor.py | 32 +++++++- tests/config/test_logging.py | 38 +--------- tests/config/test_schema.py | 58 ++++++--------- utils/msl/__init__.py | 2 + utils/msl/memberships.py | 97 +++++++++++++++++++----- 20 files changed, 525 insertions(+), 235 deletions(-) diff --git a/cogs/config.py b/cogs/config.py index 4d51eed74..3a943c53e 100644 --- a/cogs/config.py +++ b/cogs/config.py @@ -36,6 +36,47 @@ # NOTE: The greatest number of suggestions that Discord will display at once. MAXIMUM_AUTOCOMPLETE_SUGGESTIONS: "Final[int]" = 25 +# NOTE: The greatest number of characters Discord will accept within a single message. +MAXIMUM_MESSAGE_LENGTH: "Final[int]" = 2000 + +TRUNCATION_NOTICE: "Final[str]" = "\n...(truncated)" + +_EMPTY_ERROR_DETAILS_BLOCK: "Final[str]" = "```\n\n```" + + +def _truncated(message: str) -> str: + """Shorten the given message to the greatest length that Discord will accept.""" + if len(message) <= MAXIMUM_MESSAGE_LENGTH: + return message + + return ( + message[: MAXIMUM_MESSAGE_LENGTH - len(TRUNCATION_NOTICE)].rstrip() + TRUNCATION_NOTICE + ) + + +def _with_error_details(message: str, configuration_error: object) -> str: + """ + Append the given error to the given message, within a code block. + + The error is shortened where it would take the message beyond the greatest length + Discord will accept, because a message that is too long is refused in its entirety. + A configuration holding many errors would otherwise leave whoever ran the command + with no diagnostic at all, at exactly the moment one is most needed. + """ + AVAILABLE_LENGTH: Final[int] = ( + MAXIMUM_MESSAGE_LENGTH - len(message) - len(_EMPTY_ERROR_DETAILS_BLOCK) + ) + + error_details: str = str(configuration_error) + + if len(error_details) > AVAILABLE_LENGTH: + error_details = ( + error_details[: max(AVAILABLE_LENGTH - len(TRUNCATION_NOTICE), 0)].rstrip() + + TRUNCATION_NOTICE + ) + + return f"{message}```\n{error_details}\n```" + def _format_settings_list(settings_names: "Iterable[str]") -> str: """Format the given settings key paths into a bulleted list, truncated if very long.""" @@ -77,13 +118,17 @@ class ConfigCommandsCog(TeXBotBaseCog): @staticmethod async def autocomplete_get_settings_names( ctx: "TeXBotAutocompleteContext", - ) -> "AbstractSet[discord.OptionChoice] | AbstractSet[str]": + ) -> "Sequence[discord.OptionChoice]": """ - Autocomplete callable that generates the set of configurable settings names. + Autocomplete callable that generates the configurable settings names, in order. Every setting whose name contains what has been typed so far is suggested, rather than only those beginning with it, because each name is prefixed with the section holding it & so is rarely typed from its beginning. + + NOTE: Returned as a sequence, rather than the set that every other autocomplete + callable returns, because these suggestions are alphabetically ordered & a set + would discard that ordering. """ TYPED_VALUE: Final[str] = str(ctx.value or "").strip().lower() @@ -93,12 +138,12 @@ async def autocomplete_get_settings_names( if TYPED_VALUE in setting_name.lower() ] - return { + return [ discord.OptionChoice(name=setting_name, value=setting_name) # NOTE: Sliced because Discord refuses a response holding more suggestions # than it is willing to display. for setting_name in MATCHING_SETTINGS_NAMES[:MAXIMUM_AUTOCOMPLETE_SUGGESTIONS] - } + ] @config.command( name="reload", @@ -124,11 +169,13 @@ async def reload(self, ctx: "TeXBotApplicationContext") -> None: except SettingsValidationError as configuration_error: logger.warning("Configuration reload rejected:\n%s", configuration_error) await ctx.respond( - ( - ":x: The configuration file was **not** loaded, " - "because it contains invalid settings. " - "No changes have been applied.\n" - f"```\n{configuration_error}\n```" + _with_error_details( + ( + ":x: The configuration file was **not** loaded, " + "because it contains invalid settings. " + "No changes have been applied.\n" + ), + configuration_error, ), ephemeral=True, ) @@ -140,10 +187,12 @@ async def reload(self, ctx: "TeXBotApplicationContext") -> None: ) as configuration_error: logger.warning("Configuration reload failed: %s", configuration_error) await ctx.respond( - ( - ":x: The configuration file could not be read, " - "so no changes have been applied.\n" - f"```\n{configuration_error}\n```" + _with_error_details( + ( + ":x: The configuration file could not be read, " + "so no changes have been applied.\n" + ), + configuration_error, ), ephemeral=True, ) @@ -229,7 +278,10 @@ async def get(self, ctx: "TeXBotApplicationContext", setting_name: str) -> None: # since then from looking as though it had simply been ignored. if config.settings.file_has_changed(): response_message += config.format_file_difference( - setting_name, CURRENT_VALUE, secret=setting_metadata.secret + setting_name, + CURRENT_VALUE, + secret=setting_metadata.secret, + file_path=config.settings.file_path, ) if setting_metadata.description: @@ -240,7 +292,9 @@ async def get(self, ctx: "TeXBotApplicationContext", setting_name: str) -> None: "\n\n:warning: Changing this setting requires TeX-Bot to be restarted." ) - await ctx.respond(response_message, ephemeral=True) + # NOTE: Shortened because the difference described above holds the error that + # reading an edited file raised, which has no bounded length of its own. + await ctx.respond(_truncated(response_message), ephemeral=True) @config.command( name="set", @@ -351,11 +405,13 @@ async def unset(self, ctx: "TeXBotApplicationContext", setting_name: str) -> Non return except SettingsValidationError as validation_error: await ctx.respond( - ( - f":x: **`{setting_name}`** was **not** removed, because the " - f"configuration would no longer be valid without it. " - f"Nothing has been changed.\n" - f"```\n{validation_error}\n```" + _with_error_details( + ( + f":x: **`{setting_name}`** was **not** removed, because the " + f"configuration would no longer be valid without it. " + f"Nothing has been changed.\n" + ), + validation_error, ), ephemeral=True, ) @@ -432,10 +488,12 @@ async def _respond_with_rejected_change( logger.warning("Change to %r rejected:\n%s", setting_name, validation_error) await ctx.respond( - ( - f":x: **`{setting_name}`** was **not** changed, because that value is " - f"not valid. Nothing has been changed.\n" - f"```\n{validation_error}\n```" + _with_error_details( + ( + f":x: **`{setting_name}`** was **not** changed, because that value is " + f"not valid. Nothing has been changed.\n" + ), + validation_error, ), ephemeral=True, ) @@ -448,10 +506,12 @@ async def _respond_with_unusable_file( logger.warning("Configuration file could not be used: %s", configuration_error) await ctx.respond( - ( - ":x: The configuration file could not be read or written, " - "so nothing has been changed.\n" - f"```\n{configuration_error}\n```" + _with_error_details( + ( + ":x: The configuration file could not be read or written, " + "so nothing has been changed.\n" + ), + configuration_error, ), ephemeral=True, ) diff --git a/cogs/ping.py b/cogs/ping.py index ce372173b..abb4a3705 100644 --- a/cogs/ping.py +++ b/cogs/ping.py @@ -25,8 +25,10 @@ async def ping(self, ctx: "TeXBotApplicationContext") -> None: await ctx.respond( random.choices( # noqa: S311 ["Pong!", "`64 bytes from TeX-Bot: icmp_seq=1 ttl=63 time=0.01 ms`"], + # NOTE: The probability is held as a fraction between 0 & 1, so the + # weight of the common response is the remainder of that same scale. weights=( - 100 - settings.commands.ping.easter_egg_probability, + 1 - settings.commands.ping.easter_egg_probability, settings.commands.ping.easter_egg_probability, ), )[0], diff --git a/cogs/startup.py b/cogs/startup.py index 09b5118d3..f47f15a80 100644 --- a/cogs/startup.py +++ b/cogs/startup.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING import discord -from discord_logging.handler import DiscordHandler import utils from config import settings @@ -19,7 +18,7 @@ RolesChannelDoesNotExistError, ) from utils import TeXBotBaseCog -from utils.msl import fetch_community_group_members_list +from utils.msl import fetch_community_group_members_list, msl_is_configured if TYPE_CHECKING: from collections.abc import Sequence @@ -42,28 +41,10 @@ async def on_ready(self) -> None: Shortcut accessors should only be populated once TeX-Bot is ready to make API requests. """ - if settings.logging.discord_channel is not None: - discord_logging_handler: logging.Handler = DiscordHandler( - service_name=self.bot.user.name if self.bot.user else "TeX-Bot", - webhook_url=str(settings.logging.discord_channel.webhook_url), - avatar_url=( - self.bot.user.avatar.url - if self.bot.user and self.bot.user.avatar - else None - ), - ) - discord_logging_handler.setLevel(logging.WARNING) - discord_logging_handler.setFormatter( - logging.Formatter("{levelname} | {message}", style="{") - ) - - logger.addHandler(discord_logging_handler) - - else: - logger.warning( - "DISCORD_LOG_CHANNEL_WEBHOOK_URL was not set, " - "so error logs will not be sent to the Discord log channel." - ) + # NOTE: Relaying error logs to a Discord log channel is set up from the loaded + # configuration (& re-applied upon every reload), rather than here. Attaching a + # handler here as well would relay every error twice, & once more for every + # gateway re-identify, because this listener runs again upon each reconnection. try: main_guild: discord.Guild | None = self.bot.main_guild @@ -109,13 +90,21 @@ async def on_ready(self) -> None: if not discord.utils.get(main_guild.text_channels, name="general"): logger.warning(GeneralChannelDoesNotExistError()) - try: - await fetch_community_group_members_list() - except MSLMembershipError as msl_membership_error: - logger.debug( - "Failed to update community group member list cache on startup: %s", - msl_membership_error, + if not msl_is_configured(): + logger.warning( + "Both 'community-group:msl:organisation-id' & " + "'community-group:msl:auth-cookie' must be set for your group's " + "members-list to be retrieved. Every feature that depends upon knowing " + "who holds a membership will not work until they are." ) + else: + try: + await fetch_community_group_members_list() + except MSLMembershipError as msl_membership_error: + logger.debug( + "Failed to update community group member list cache on startup: %s", + msl_membership_error, + ) if settings.commands.strike.performed_manually_warning_location != "DM": manual_moderation_warning_message_location_exists: bool = bool( diff --git a/cogs/strike.py b/cogs/strike.py index 9ea960c92..b8829f01c 100644 --- a/cogs/strike.py +++ b/cogs/strike.py @@ -231,6 +231,17 @@ class BaseStrikeCog(TeXBotBaseCog): async def _send_strike_user_message( self, strike_user: discord.User | discord.Member, member_strikes: DiscordMemberStrikes ) -> None: + # NOTE: The link to the moderation document is optional, so the sentence pointing + # at it is left out entirely rather than sent holding a link that goes nowhere. + MODERATION_POLICY_MESSAGE: Final[str] = ( + f"To find what moderation action corresponds to which strike level, " + f"you can view " + f"the {self.bot.group_short_name} Discord server moderation document " + f"[here](<{settings.community_group.links.moderation_policy}>)\n" + if settings.community_group.links.moderation_policy is not None + else "" + ) + try: await strike_user.send( "Hi, a recent incident occurred in which you may have broken one or more of " @@ -238,10 +249,7 @@ async def _send_strike_user_message( "We have increased the number of strikes associated with your account " f"to {min(3, member_strikes.strikes)} and " "the corresponding moderation action will soon be applied to you. " - "To find what moderation action corresponds to which strike level, " - "you can view " - f"the {self.bot.group_short_name} Discord server moderation document " - f"[here](<{settings.community_group.links.moderation_policy}>)\n" + f"{MODERATION_POLICY_MESSAGE}" "Please ensure you have read " f"the rules in {await self.bot.get_mention_string(self.bot.rules_channel)} so " "that your future behaviour adheres to them." diff --git a/config/_accessor.py b/config/_accessor.py index ecca0ecb4..c8abfa78f 100644 --- a/config/_accessor.py +++ b/config/_accessor.py @@ -12,8 +12,8 @@ from pydantic import BaseModel, ValidationError -from ._document import SettingsDocument -from ._schema import SettingsSchema +from ._document import InvalidSettingsFileError, SettingsDocument, SettingsFileNotFoundError +from ._schema import SettingsSchema, nested_settings_model_of, setting_names_within if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -21,6 +21,8 @@ from pathlib import Path from typing import Final + from pydantic.fields import FieldInfo + from ._schema import ( CommandsSettings, CommunityGroupSettings, @@ -78,14 +80,27 @@ def _flatten_settings(model: BaseModel, prefix: str = "") -> "Mapping[str, objec flattened_settings: dict[str, object] = {} field_name: str - for field_name in type(model).model_fields: + field: FieldInfo + for field_name, field in type(model).model_fields.items(): value: object = getattr(model, field_name) KEY_PATH: str = f"{prefix}{field_name.replace('_', '-')}" if isinstance(value, BaseModel): flattened_settings.update(_flatten_settings(value, prefix=f"{KEY_PATH}:")) - else: - flattened_settings[KEY_PATH] = value + continue + + NESTED_MODEL: type[BaseModel] | None = nested_settings_model_of(field) + if NESTED_MODEL is not None: + # NOTE: An optional section left out of the file holds no value of its own; + # every setting within it is simply unset. Naming those settings, rather than + # the section holding them, keeps every key path reported by a reload one that + # the `/config` command recognises. + flattened_settings.update( + dict.fromkeys(setting_names_within(NESTED_MODEL, prefix=f"{KEY_PATH}:")) + ) + continue + + flattened_settings[KEY_PATH] = value return flattened_settings @@ -136,11 +151,18 @@ def file_has_changed(self) -> bool: Compared as the documents parse to, rather than as raw text, so that a file rewritten with different line endings is not mistaken for one that was edited. + + A file that can no longer be read at all is reported as changed, rather than + raising: it certainly no longer holds the configuration that was loaded from it, + & saying so is what allows the caller to explain that instead of failing. """ if self._loaded is None: return False - return SettingsDocument.load(self.file_path).dump() != self._loaded.document.dump() + try: + return SettingsDocument.load(self.file_path).dump() != self._loaded.document.dump() + except (SettingsFileNotFoundError, InvalidSettingsFileError, OSError): + return True def reload(self, file_path: "Path | None" = None) -> "AbstractSet[str]": """ diff --git a/config/_document.py b/config/_document.py index e1b3fe686..f4d77d929 100644 --- a/config/_document.py +++ b/config/_document.py @@ -12,6 +12,7 @@ import io import logging import os +import stat from pathlib import Path from typing import TYPE_CHECKING @@ -252,6 +253,35 @@ def dump(self) -> str: return output_buffer.getvalue() + def _permissions_of_existing_file(self) -> int | None: + """Return the permission bits of this document's file, if it can be determined.""" + stat_error: OSError + try: + return stat.S_IMODE(self._file_path.stat().st_mode) + except OSError as stat_error: + logger.debug( + "Could not read the permissions of %s (%s); " + "the file will be written with the permissions a new file is given.", + self._file_path, + stat_error.strerror or stat_error, + ) + return None + + @staticmethod + def _write_privately(file_path: Path, contents: str) -> None: + """ + Write the given contents into the given path, readable only by its owner. + + The configuration file holds the bot token & the MSL authentication cookie, so + it must never exist (even momentarily, as the temporary file written below does) + with the permissions a newly created file would otherwise be given. + """ + file_descriptor: int = os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + + opened_file: io.TextIOWrapper + with os.fdopen(file_descriptor, "w", encoding="utf-8") as opened_file: + opened_file.write(contents) + def write(self) -> None: """ Persist this document to disk, atomically where the filesystem allows it. @@ -265,15 +295,24 @@ def write(self) -> None: container, because a rename cannot replace a mount point. Writing directly is not atomic, but it is the only option available in that case; mounting the directory holding the configuration file, rather than the file itself, avoids it entirely. + + The permissions of the file being replaced are carried across onto the file + replacing it, so that a deployment which has deliberately restricted who may read + its configuration file does not silently have those restrictions lifted. """ NEW_FILE_CONTENTS: Final[str] = self.dump() + EXISTING_FILE_PERMISSIONS: Final[int | None] = self._permissions_of_existing_file() + temporary_file_path: Path = self._file_path.with_name( f"{self._file_path.name}.{os.getpid()}.tmp" ) try: - temporary_file_path.write_text(NEW_FILE_CONTENTS, encoding="utf-8") + self._write_privately(temporary_file_path, NEW_FILE_CONTENTS) + + if EXISTING_FILE_PERMISSIONS is not None: + temporary_file_path.chmod(EXISTING_FILE_PERMISSIONS) replace_error: OSError try: diff --git a/config/_editor.py b/config/_editor.py index 30056a14f..307a1f1f0 100644 --- a/config/_editor.py +++ b/config/_editor.py @@ -30,6 +30,7 @@ if TYPE_CHECKING: from collections.abc import Mapping, Sequence + from pathlib import Path from typing import Final from ._schema import ConfigSettingMetadata @@ -200,18 +201,27 @@ def _validated(document: SettingsDocument) -> SettingsDocument: return document -def format_file_difference(setting_name: str, running_value: object, *, secret: bool) -> str: +def format_file_difference( + setting_name: str, + running_value: object, + *, + secret: bool, + file_path: "Path | None" = None, +) -> str: """ Describe how the configuration file's value for a setting differs from the running one. Intended to be shown alongside the value TeX-Bot is running, once the file is known to have been edited since it was last loaded, so that whoever is looking at a setting can see what reloading would change it to rather than only being told to reload. + + The file compared against is the one the running configuration was loaded from, which + must be given explicitly: it is not necessarily the one that would be located afresh. """ file_error: Exception try: FILE_VALUES: Final[Mapping[str, object]] = _flatten_settings( - _snapshot_of(SettingsDocument.load()) + _snapshot_of(SettingsDocument.load(file_path)) ) except ( SettingsValidationError, @@ -259,8 +269,14 @@ def _read_unchanged_file(loaded_document: SettingsDocument | None) -> SettingsDo A change made by hand is refused rather than merged into, so that changing one setting cannot silently apply somebody else's unrelated edits alongside it, nor report that TeX-Bot must be restarted for a setting its author never touched. + + The file read is the one the given document was loaded from, so that a change is + always made to the file TeX-Bot is running, rather than to whichever file would be + located afresh. """ - document: SettingsDocument = SettingsDocument.load() + document: SettingsDocument = SettingsDocument.load( + None if loaded_document is None else loaded_document.file_path + ) # NOTE: Nothing has been loaded for the file to have diverged from while TeX-Bot is # still starting up, so there is nothing to compare it against. diff --git a/config/_schema.py b/config/_schema.py index 6fff8d335..6605e41ce 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -57,6 +57,8 @@ "StatsCommandSettings", "StrikeCommandSettings", "get_settings_metadata", + "nested_settings_model_of", + "setting_names_within", ) @@ -350,9 +352,10 @@ class DiscordSettings(_BaseSettingsSchema): # type: ignore[explicit-any] AfterValidator( lambda token: _ensure_matches( token, + # NOTE: Every segment is base64url-encoded, so any of them may contain + # `-` or `_`: the middle segment (an encoded timestamp) routinely does. re.compile( - r"\A(?!.*__.*)(?!.*--.*)" - r"(?:([A-Za-z0-9]{24,26})\.([A-Za-z0-9]{6})\.([A-Za-z0-9_-]{27,38}))\Z" + r"\A([A-Za-z0-9_-]{24,26})\.([A-Za-z0-9_-]{6})\.([A-Za-z0-9_-]{27,38})\Z" ), "a Discord bot token", ) @@ -474,7 +477,9 @@ class CommunityGroupSettings(_BaseSettingsSchema): # type: ignore[explicit-any] "If this is not set, the group's full name will be retrieved " "from the name of your group's Discord guild." ), - json_schema_extra={"requires_restart": False, "secret": False}, + # NOTE: Requires a restart because the name of the ID argument to `/make-member` + # is derived from this, & so is fixed when that command is registered. + json_schema_extra={"requires_restart": True, "secret": False}, ) short_name: str | None = Field( default=None, @@ -485,7 +490,9 @@ class CommunityGroupSettings(_BaseSettingsSchema): # type: ignore[explicit-any] "If this is not set, the group's short name will be determined " "from your group's full name." ), - json_schema_extra={"requires_restart": False, "secret": False}, + # NOTE: Requires a restart because the descriptions of the `/stats` commands are + # derived from this, & so are fixed when those commands are registered. + json_schema_extra={"requires_restart": True, "secret": False}, ) membership_dependent_roles: UniqueStrSequence = Field( # NOTE: Defaults to no roles, rather than being absent, so that consumers can @@ -732,6 +739,24 @@ def _holds_text(annotation: object) -> bool: ) +def nested_settings_model_of(field: "FieldInfo") -> "type[BaseModel] | None": + """ + Return the settings section that the given field holds, if it holds one at all. + + A field's annotation may be a union (an optional section, for example), so every + member of it must be searched to find the nested model it may contain. + """ + return next( + ( + annotation_argument + for annotation_argument in (get_args(field.annotation) or (field.annotation,)) + if isinstance(annotation_argument, type) + and issubclass(annotation_argument, BaseModel) + ), + None, + ) + + def _walk_settings_metadata( model: type[BaseModel], prefix: str = "" ) -> "Iterator[tuple[str, ConfigSettingMetadata]]": @@ -741,17 +766,7 @@ def _walk_settings_metadata( for field_name, field in model.model_fields.items(): KEY_PATH: str = f"{prefix}{field_name.replace('_', '-')}" - # NOTE: A field's annotation may be a union (an optional section, for example), so - # every member of it must be searched to find the nested model it may contain. - nested_model: type[BaseModel] | None = next( - ( - annotation_argument - for annotation_argument in (get_args(field.annotation) or (field.annotation,)) - if isinstance(annotation_argument, type) - and issubclass(annotation_argument, BaseModel) - ), - None, - ) + nested_model: type[BaseModel] | None = nested_settings_model_of(field) if nested_model is not None: yield from _walk_settings_metadata(nested_model, prefix=f"{KEY_PATH}:") continue @@ -771,6 +786,18 @@ def _walk_settings_metadata( ) +def setting_names_within(model: type[BaseModel], prefix: str = "") -> "Iterator[str]": + """ + Yield the key path of every individual setting declared within the given model. + + Derived from the same walk that produces the settings metadata, so that a name + yielded here is always one that the `/config` command recognises. + """ + key_path: str + for key_path, _ in _walk_settings_metadata(model, prefix=prefix): + yield key_path + + def get_settings_metadata() -> "Mapping[str, ConfigSettingMetadata]": """ Return the metadata of every configuration setting, keyed by its key path. diff --git a/exceptions/__init__.py b/exceptions/__init__.py index 83acf460c..e95eca01b 100644 --- a/exceptions/__init__.py +++ b/exceptions/__init__.py @@ -4,7 +4,6 @@ from .committee_actions import InvalidActionDescriptionError, InvalidActionTargetError from .config_changes import ( - ChangingSettingWithRequiredSiblingError, ImproperlyConfiguredError, RestartRequiredDueToConfigChange, ) @@ -37,7 +36,6 @@ __all__: "Sequence[str]" = ( "ApplicantRoleDoesNotExistError", "ArchivistRoleDoesNotExistError", - "ChangingSettingWithRequiredSiblingError", "ChannelDoesNotExistError", "CommitteeElectRoleDoesNotExistError", "CommitteeRoleDoesNotExistError", diff --git a/exceptions/config_changes.py b/exceptions/config_changes.py index 1f38ff71e..1515ab532 100644 --- a/exceptions/config_changes.py +++ b/exceptions/config_changes.py @@ -11,7 +11,6 @@ from collections.abc import Set as AbstractSet __all__: "Sequence[str]" = ( - "ChangingSettingWithRequiredSiblingError", "ImproperlyConfiguredError", "RestartRequiredDueToConfigChange", ) @@ -42,32 +41,3 @@ def __init__( self.changed_settings: AbstractSet[str] = changed_settings or set() super().__init__(message) - - -class ChangingSettingWithRequiredSiblingError(BaseTeXBotError, ValueError): - """Exception class for when a setting cannot be changed because of required siblings.""" - - @classproperty - @override - def DEFAULT_MESSAGE(cls) -> str: - return ( - "The given setting cannot be changed " - "because it has one or more required sibling settings that must be set first." - ) - - @override - def __init__( - self, message: str | None = None, config_setting_name: str | None = None - ) -> None: - """Initialise an Exception for changing a setting with unset required siblings.""" - self.config_setting_name: str | None = config_setting_name - - super().__init__( - message - or ( - f"Cannot assign a value to config setting {config_setting_name!r} because " - f"it has one or more required sibling settings that must be set first." - if config_setting_name - else None - ) - ) diff --git a/stubs/discord/commands/options.pyi b/stubs/discord/commands/options.pyi index e39ce3574..98e59dcb9 100644 --- a/stubs/discord/commands/options.pyi +++ b/stubs/discord/commands/options.pyi @@ -6,6 +6,10 @@ from discord.commands.context import AutocompleteContext __all__: Sequence[str] = ("Option", "OptionChoice", "option") +type AutocompleteValues = ( + AbstractSet[OptionChoice] | AbstractSet[str] | Sequence[OptionChoice] | Sequence[str] +) + class Option: ... class OptionChoice: @@ -25,14 +29,8 @@ def option[**P, **Q, T, T_context: AutocompleteContext]( | AbstractSet[int] | AbstractSet[float] = ..., parameter_name: str = ..., - autocomplete: Callable[ - [T_context], - Awaitable[AbstractSet[OptionChoice] | AbstractSet[str]], - ] - | Callable[ - [T_context], - Awaitable[AbstractSet[OptionChoice] | AbstractSet[str] | AbstractSet[int]], - ] = ..., + autocomplete: Callable[[T_context], Awaitable[AutocompleteValues]] + | Callable[[T_context], Awaitable[AutocompleteValues | AbstractSet[int]]] = ..., ) -> Callable[[Callable[P, Awaitable[None]]], Callable[Q, Awaitable[None]]]: ... @overload def option[**P, **Q, T_context: AutocompleteContext]( @@ -46,14 +44,8 @@ def option[**P, **Q, T_context: AutocompleteContext]( | AbstractSet[str] | AbstractSet[int] | AbstractSet[float] = ..., - autocomplete: Callable[ - [T_context], - Awaitable[AbstractSet[OptionChoice] | AbstractSet[str]], - ] - | Callable[ - [T_context], - Awaitable[AbstractSet[OptionChoice] | AbstractSet[str] | AbstractSet[int]], - ] = ..., + autocomplete: Callable[[T_context], Awaitable[AutocompleteValues]] + | Callable[[T_context], Awaitable[AutocompleteValues | AbstractSet[int]]] = ..., required: bool = ..., min_length: int = ..., max_length: int = ..., diff --git a/tests/config/conftest.py b/tests/config/conftest.py index 603ba807f..286424ade 100644 --- a/tests/config/conftest.py +++ b/tests/config/conftest.py @@ -1,11 +1,15 @@ """Shared fixtures & constants for the config package test suite.""" +import logging from typing import TYPE_CHECKING import pytest +from config._logging import DISCORD_LOGGER_NAME, LOGGER_NAME + if TYPE_CHECKING: - from collections.abc import Callable, Sequence + from collections.abc import Callable, Iterator, Sequence + from logging import Handler, Logger from pathlib import Path from typing import Final @@ -45,6 +49,41 @@ """ +@pytest.fixture(autouse=True) +def _restore_loggers() -> "Iterator[None]": + """ + Restore both loggers to the state they were in before each test. + + Logging is process-wide, so without this any test that applied a configuration + (including every test that reloads one, or runs `/config reload`) would leave its + handlers, level & propagation attached for every test that ran afterwards, making + the results depend upon the order the tests happened to be collected in. + """ + tex_bot_logger: Logger = logging.getLogger(LOGGER_NAME) + discord_logger: Logger = logging.getLogger(DISCORD_LOGGER_NAME) + + ORIGINAL_STATE: Final[Sequence[tuple[Logger, Sequence[Handler], int, bool]]] = tuple( + ( + single_logger, + tuple(single_logger.handlers), + single_logger.level, + single_logger.propagate, + ) + for single_logger in (tex_bot_logger, discord_logger) + ) + + yield + + single_logger: Logger + original_handlers: Sequence[Handler] + original_level: int + original_propagate: bool + for single_logger, original_handlers, original_level, original_propagate in ORIGINAL_STATE: + single_logger.handlers = list(original_handlers) + single_logger.setLevel(original_level) + single_logger.propagate = original_propagate + + @pytest.fixture() def write_config(tmp_path: "Path") -> "ConfigWriter": """Return a callable writing the given YAML into a configuration file.""" diff --git a/tests/config/test_accessor.py b/tests/config/test_accessor.py index a13ad61ad..3144e60a0 100644 --- a/tests/config/test_accessor.py +++ b/tests/config/test_accessor.py @@ -78,6 +78,28 @@ def test_nothing_loaded_has_not_been_changed_underneath() -> None: """ assert not SettingsAccessor().file_has_changed() + @staticmethod + @pytest.mark.parametrize("edited_contents", ("discord: {unclosed\n", "", None)) + def test_an_unreadable_file_has_been_changed_underneath( + config_file: "Path", edited_contents: str | None + ) -> None: + """ + Test that a file which can no longer be read at all is reported as changed. + + A file edited into a state that cannot be parsed certainly no longer holds the + configuration loaded from it. Saying so (rather than raising) is what allows + `/config get` to explain the problem instead of failing without a response. + """ + settings: SettingsAccessor = SettingsAccessor() + settings.reload(config_file) + + if edited_contents is None: + config_file.unlink() + else: + config_file.write_text(edited_contents, encoding="utf-8") + + assert settings.file_has_changed() + @staticmethod def test_loading_makes_settings_available(config_file: "Path") -> None: """Test that settings can be read once a configuration has been loaded.""" @@ -168,11 +190,11 @@ def test_a_disappearing_section_reports_the_settings_it_removed( """ Test that removing an optional section reports the settings it took with it. - A section that is present is expanded into the settings within it, whereas one - that is absent collapses to a single empty value under its own name. Comparing - one configuration against the other must therefore distinguish a key that is - missing from a key that is present but holds nothing, or removing the section - would appear to change nothing at all. + A section is expanded into the settings within it whether it is present or not, + holding nothing where it is absent, so that removing it is reported as the + individual settings it unset rather than as the section itself. Reporting the + section would name something that is not a setting at all, & that `/config get` + would therefore refuse to show. """ settings: SettingsAccessor = SettingsAccessor() settings.reload( @@ -189,7 +211,10 @@ def test_a_disappearing_section_reports_the_settings_it_removed( ) assert "logging:discord-channel:webhook-url" in CHANGED_SETTINGS - assert "logging:discord-channel" in CHANGED_SETTINGS + assert "logging:discord-channel:log-level" in CHANGED_SETTINGS + # NOTE: The section itself is never reported: it is not a setting that could be + # looked up, so naming it would be naming something `/config get` would refuse. + assert "logging:discord-channel" not in CHANGED_SETTINGS assert settings.logging.discord_channel is None @staticmethod diff --git a/tests/config/test_command.py b/tests/config/test_command.py index 00f49bc30..366bc5f38 100644 --- a/tests/config/test_command.py +++ b/tests/config/test_command.py @@ -218,6 +218,35 @@ def test_a_rejected_secret_is_not_echoed_back( assert "was **not** loaded" in RESPONSES[0] assert INVALID_TOKEN not in RESPONSES[0] + @staticmethod + def test_a_configuration_holding_many_errors_still_fits_within_one_message( + run_config_reload: "ConfigReloadRunner", + ) -> None: + """ + Test that a great many validation failures are shortened rather than refused. + + Discord rejects a message beyond its length limit in its entirety, so a response + that was not shortened would leave the committee member with no explanation at + all, at exactly the moment one is most needed. + """ + from cogs.config import MAXIMUM_MESSAGE_LENGTH, TRUNCATION_NOTICE # noqa: PLC0415 + + UNKNOWN_SETTINGS: Final[str] = "".join( + f"unknown-setting-{unknown_setting_number}: {unknown_setting_number}\n" + for unknown_setting_number in range(100) + ) + + run_config_reload(MINIMAL_CONFIG) + + RESPONSES: Final[Sequence[str]] = run_config_reload( + f"{MINIMAL_CONFIG}{UNKNOWN_SETTINGS}" + ) + + assert "was **not** loaded" in RESPONSES[0] + assert len(RESPONSES[0]) <= MAXIMUM_MESSAGE_LENGTH + assert RESPONSES[0].endswith("```") + assert TRUNCATION_NOTICE in RESPONSES[0] + @staticmethod def test_a_missing_configuration_file_is_reported( run_config_reload: "ConfigReloadRunner", diff --git a/tests/config/test_document.py b/tests/config/test_document.py index 434e33c17..4de442ed6 100644 --- a/tests/config/test_document.py +++ b/tests/config/test_document.py @@ -1,6 +1,7 @@ """Test suite for reading, writing & error-reporting of the configuration file.""" import os +import stat from typing import TYPE_CHECKING from unittest import mock @@ -170,6 +171,30 @@ def _rejecting_replace(*_args: object, **_kwargs: object) -> None: """Stand in for a rename that the filesystem refuses.""" raise OSError(16, "Device or resource busy") + @staticmethod + @pytest.mark.skipif( + os.name == "nt", reason="Windows does not hold the permission bits being asserted." + ) + @pytest.mark.parametrize("original_permissions", (0o600, 0o640)) + def test_writing_retains_the_permissions_of_the_file_it_replaces( + write_config: "ConfigWriter", original_permissions: int + ) -> None: + """ + Test that writing does not widen who may read the configuration file. + + The file holds the bot token & the MSL authentication cookie, so a deployment + that has deliberately restricted who may read it must not have those + restrictions lifted simply because a setting was changed. + """ + config_file_path: Path = write_config(MINIMAL_CONFIG) + config_file_path.chmod(original_permissions) + + document: SettingsDocument = SettingsDocument.load(config_file_path) + document.set_value(["community-group", "full-name"], "CompSoc") + document.write() + + assert stat.S_IMODE(config_file_path.stat().st_mode) == original_permissions + @staticmethod def test_no_temporary_file_is_left_behind( write_config: "ConfigWriter", tmp_path: "Path" @@ -239,8 +264,10 @@ def test_failing_to_write_leaves_the_original_file_intact( document.raw["community-group"]["full-name"] = "ShouldNotAppear" with ( - mock.patch( - "pathlib.Path.write_text", side_effect=OSError(28, "No space left on device") + mock.patch.object( + SettingsDocument, + "_write_privately", + side_effect=OSError(28, "No space left on device"), ), pytest.raises(OSError, match="No space left on device"), ): diff --git a/tests/config/test_editor.py b/tests/config/test_editor.py index 1ff0c2bef..593168883 100644 --- a/tests/config/test_editor.py +++ b/tests/config/test_editor.py @@ -13,7 +13,11 @@ ) from config._accessor import SettingsAccessor from config._document import SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, SettingsDocument -from config._editor import format_setting_value, parse_setting_value +from config._editor import ( + format_setting_value, + parse_setting_value, + validated_document_with_setting_set, +) from .conftest import ( CHANGED_EASTER_EGG_PROBABILITY, @@ -457,6 +461,32 @@ def test_a_change_made_from_discord_does_not_refuse_the_next_one( assert config.settings.community_group.full_name == "CompSoc" + @staticmethod + def test_a_change_is_made_against_the_file_that_was_loaded( + write_config: "ConfigWriter", tmp_path: "Path", monkeypatch: pytest.MonkeyPatch + ) -> None: + """ + Test that a change is made to the file the given configuration was loaded from. + + The file TeX-Bot loaded is not necessarily the one that would be located afresh, + so resolving it by location (rather than from the document the change is made + against) would compare against, validate & rewrite an entirely different file. + """ + LOADED_DOCUMENT: Final[SettingsDocument] = SettingsDocument.load( + write_config(COMMENTED_CONFIG) + ) + + OTHER_FILE_PATH: Final[Path] = tmp_path / "elsewhere.yaml" + OTHER_FILE_PATH.write_text(MINIMAL_CONFIG, encoding="utf-8") + monkeypatch.setenv(SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME, str(OTHER_FILE_PATH)) + + UPDATED_DOCUMENT: Final[SettingsDocument] = validated_document_with_setting_set( + "community-group:full-name", "CompSoc", LOADED_DOCUMENT + ) + + assert UPDATED_DOCUMENT.file_path == LOADED_DOCUMENT.file_path + assert OTHER_FILE_PATH.read_text(encoding="utf-8") == MINIMAL_CONFIG + @staticmethod def test_an_unedited_file_is_not_reported_as_changed( configured: "Callable[[str], Path]", diff --git a/tests/config/test_logging.py b/tests/config/test_logging.py index f8fb6c61d..7ed80693a 100644 --- a/tests/config/test_logging.py +++ b/tests/config/test_logging.py @@ -3,7 +3,6 @@ import logging from typing import TYPE_CHECKING -import pytest from discord_logging.handler import DiscordHandler from config._logging import ( @@ -16,8 +15,8 @@ from .conftest import VALID_BOT_TOKEN, VALID_MAIN_GUILD_ID, VALID_WEBHOOK_URL if TYPE_CHECKING: - from collections.abc import Iterator, Mapping, Sequence - from logging import Handler, Logger + from collections.abc import Mapping, Sequence + from logging import Handler from pathlib import Path from typing import Final @@ -39,39 +38,6 @@ def _logging_settings(**logging_overrides: object) -> "LoggingSettings": ).logging -@pytest.fixture(autouse=True) -def _restore_loggers() -> "Iterator[None]": - """ - Restore both loggers to the state they were in before each test. - - Logging is process-wide, so without this a test applying a configuration would - leave its handlers attached for every test that ran afterwards. - """ - tex_bot_logger: Logger = logging.getLogger(LOGGER_NAME) - discord_logger: Logger = logging.getLogger(DISCORD_LOGGER_NAME) - - ORIGINAL_STATE: Final[Sequence[tuple[Logger, Sequence[Handler], int, bool]]] = tuple( - ( - single_logger, - tuple(single_logger.handlers), - single_logger.level, - single_logger.propagate, - ) - for single_logger in (tex_bot_logger, discord_logger) - ) - - yield - - single_logger: Logger - original_handlers: Sequence[Handler] - original_level: int - original_propagate: bool - for single_logger, original_handlers, original_level, original_propagate in ORIGINAL_STATE: - single_logger.handlers = list(original_handlers) - single_logger.setLevel(original_level) - single_logger.propagate = original_propagate - - def _handlers_of_type(logger_name: str, handler_type: type) -> "Sequence[Handler]": """Return every handler of the given type attached to the named logger.""" return tuple( diff --git a/tests/config/test_schema.py b/tests/config/test_schema.py index b8233f1e2..91b337533 100644 --- a/tests/config/test_schema.py +++ b/tests/config/test_schema.py @@ -266,45 +266,30 @@ def test_invalid_bot_token_is_rejected(bot_token: str) -> None: ) @staticmethod - @pytest.mark.parametrize("separator", ("--", "__")) - def test_bot_token_containing_repeated_punctuation_is_rejected(separator: str) -> None: + @pytest.mark.parametrize( + "bot_token", + ( + "MTk4NjIyNDgzNDcxOTI1MjQ4.G_h-4y.ZnCjm1XVW7vRze4b7Cq4se7kKWs", + "MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se--kKWs", + "MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se__kKWs", + ), + ) + def test_bot_token_containing_punctuation_is_accepted(bot_token: str) -> None: """ - Test that a token holding two consecutive hyphens or underscores is rejected. + Test that a token holding hyphens or underscores anywhere within it is accepted. - Discord never issues such a token, so one appearing here almost always means a - placeholder has been left in place of a real token. + Every segment of a Discord bot token is base64url-encoded, so any of them may + hold either character, & may hold two of them consecutively. The middle segment + (an encoded timestamp) routinely does. """ - REPEATED_PUNCTUATION_TOKEN: Final[str] = ( - f"{VALID_BOT_TOKEN[:-4]}{separator}{VALID_BOT_TOKEN[-2:]}" - ) - - with pytest.raises(ValidationError, match="Discord bot token"): - SettingsSchema.model_validate( - { - **REQUIRED_SETTINGS, - "discord": { - "bot-token": REPEATED_PUNCTUATION_TOKEN, - "main-guild-id": VALID_MAIN_GUILD_ID, - }, - } - ) - - @staticmethod - def test_bot_token_containing_differing_punctuation_is_accepted() -> None: - """Test that a hyphen adjacent to an underscore is not treated as a repetition.""" - MIXED_PUNCTUATION_TOKEN: Final[str] = f"{VALID_BOT_TOKEN[:-4]}-_{VALID_BOT_TOKEN[-2:]}" - settings: SettingsSchema = SettingsSchema.model_validate( { **REQUIRED_SETTINGS, - "discord": { - "bot-token": MIXED_PUNCTUATION_TOKEN, - "main-guild-id": VALID_MAIN_GUILD_ID, - }, + "discord": {"bot-token": bot_token, "main-guild-id": VALID_MAIN_GUILD_ID}, } ) - assert settings.discord.bot_token.get_secret_value() == MIXED_PUNCTUATION_TOKEN + assert settings.discord.bot_token.get_secret_value() == bot_token @staticmethod @pytest.mark.parametrize("lookback_days", (5, 1826)) @@ -555,6 +540,10 @@ def test_settings_requiring_a_restart_are_exactly_those_that_cannot_be_applied() # NOTE: Used to connect to Discord & to populate the shortcut accessors. "discord:bot-token", "discord:main-guild-id", + # NOTE: Baked into the names & descriptions of the slash commands that + # refer to your group, & so fixed when those commands are registered. + "community-group:full-name", + "community-group:short-name", # NOTE: Fixed when each recurring task is created during start-up. "community-group:msl:auto-cookie-checking:enabled", "community-group:msl:auto-cookie-checking:interval", @@ -620,11 +609,10 @@ def test_metadata_covers_every_configurable_setting() -> None: _flatten_settings(SettingsSchema.model_validate(_config())) ) - # NOTE: An omitted optional section collapses to a single key, so the metadata - # describes the sections within it that the flattened settings cannot show. - assert SETTINGS_NAMES - frozenset(get_settings_metadata()) == { - "logging:discord-channel" - } + # NOTE: Every flattened key path must name a setting that `/config` recognises, + # including those within an optional section that has been omitted entirely. + assert not SETTINGS_NAMES - frozenset(get_settings_metadata()) + assert not frozenset(get_settings_metadata()) - SETTINGS_NAMES def test_minimal_config_fixture_matches_the_required_settings() -> None: diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 0a3c37f6f..b9b12626e 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -7,6 +7,7 @@ fetch_community_group_members_list, fetch_url_content_with_session, is_id_a_community_group_member, + msl_is_configured, ) if TYPE_CHECKING: @@ -17,4 +18,5 @@ "fetch_community_group_members_list", "fetch_url_content_with_session", "is_id_a_community_group_member", + "msl_is_configured", ) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 9a7bbf305..98da672f3 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -18,12 +18,15 @@ from logging import Logger from typing import Final + from pydantic import SecretStr + __all__: "Sequence[str]" = ( "fetch_community_group_members_count", "fetch_community_group_members_list", "fetch_url_content_with_session", "is_id_a_community_group_member", + "msl_is_configured", ) @@ -35,38 +38,95 @@ "Expires": "0", } -BASE_SU_PLATFORM_WEB_COOKIES: "Mapping[str, str]" = { - ".AspNet.SharedCookie": ( - settings.community_group.msl.auth_cookie.get_secret_value() - if settings.community_group.msl.auth_cookie is not None - else "" - ), -} +AUTH_COOKIE_NAME: "Final[str]" = ".AspNet.SharedCookie" + +MEMBERS_LIST_URL_TEMPLATE: "Final[str]" = ( + "https://guildofstudents.com/organisation/memberlist/{organisation_id}/?sort=groups" +) -MEMBERS_LIST_URL: "Final[str]" = f"https://guildofstudents.com/organisation/memberlist/{settings.community_group.msl.organisation_id}/?sort=groups" +# NOTE: The cookie the SU platform handed back when it last refreshed the session, paired +# with the configured cookie it was derived from. Holding both means that a cookie set by +# `/config set` replaces this one, rather than being masked by it indefinitely. +_refreshed_auth_cookie: tuple[str, str] | None = None _membership_list_cache: set[int] = set() +def msl_is_configured() -> bool: + """Whether both of the settings required to reach your group's MSL website are set.""" + return ( + settings.community_group.msl.organisation_id is not None + and settings.community_group.msl.auth_cookie is not None + ) + + +def _configured_auth_cookie() -> str: + """ + Return the MSL authentication cookie held within the configuration. + + Read upon every request, rather than held as a constant, so that a cookie replaced + by `/config set` (the expired-cookie case this setting exists for) takes effect + without TeX-Bot needing to be restarted. + """ + AUTH_COOKIE: Final[SecretStr | None] = settings.community_group.msl.auth_cookie + + if AUTH_COOKIE is None: + NO_AUTH_COOKIE_MESSAGE: Final[str] = ( + "No 'community-group:msl:auth-cookie' was set, " + "so your group's MSL website cannot be accessed." + ) + raise MSLMembershipError(message=NO_AUTH_COOKIE_MESSAGE) + + return AUTH_COOKIE.get_secret_value() + + +def _su_platform_web_cookies() -> "Mapping[str, str]": + """Return the cookies that authenticate TeX-Bot to your group's MSL website.""" + CONFIGURED_AUTH_COOKIE: Final[str] = _configured_auth_cookie() + + if _refreshed_auth_cookie is not None and _refreshed_auth_cookie[0] == ( + CONFIGURED_AUTH_COOKIE + ): + return {AUTH_COOKIE_NAME: _refreshed_auth_cookie[1]} + + return {AUTH_COOKIE_NAME: CONFIGURED_AUTH_COOKIE} + + +def _members_list_url() -> str: + """Return the URL of your community group's members-list.""" + ORGANISATION_ID: Final[str | None] = settings.community_group.msl.organisation_id + + if ORGANISATION_ID is None: + NO_ORGANISATION_ID_MESSAGE: Final[str] = ( + "No 'community-group:msl:organisation-id' was set, " + "so your group's members-list cannot be located." + ) + raise MSLMembershipError(message=NO_ORGANISATION_ID_MESSAGE) + + return MEMBERS_LIST_URL_TEMPLATE.format(organisation_id=ORGANISATION_ID) + + async def fetch_url_content_with_session(url: str) -> str: """Fetch the HTTP content at the given URL, using a shared aiohttp session.""" - global BASE_SU_PLATFORM_WEB_COOKIES # noqa: PLW0603 + global _refreshed_auth_cookie # noqa: PLW0603 + + SU_PLATFORM_WEB_COOKIES: Final[Mapping[str, str]] = _su_platform_web_cookies() + async with ( aiohttp.ClientSession( - headers=BASE_SU_PLATFORM_WEB_HEADERS, cookies=BASE_SU_PLATFORM_WEB_COOKIES + headers=BASE_SU_PLATFORM_WEB_HEADERS, cookies=SU_PLATFORM_WEB_COOKIES ) as http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as http_response, ): - returned_asp_cookie: Morsel[str] | None = http_response.cookies.get( - ".AspNet.SharedCookie" - ) + returned_asp_cookie: Morsel[str] | None = http_response.cookies.get(AUTH_COOKIE_NAME) if returned_asp_cookie is not None and ( - returned_asp_cookie.value != BASE_SU_PLATFORM_WEB_COOKIES[".AspNet.SharedCookie"] + returned_asp_cookie.value != SU_PLATFORM_WEB_COOKIES[AUTH_COOKIE_NAME] ): logger.info("SU platform access cookie was updated by the server; updating local.") - BASE_SU_PLATFORM_WEB_COOKIES = { - ".AspNet.SharedCookie": returned_asp_cookie.value, - } + _refreshed_auth_cookie = ( + _configured_auth_cookie(), + returned_asp_cookie.value, + ) return await http_response.text() @@ -77,7 +137,8 @@ async def fetch_community_group_members_list() -> set[int]: Returns a set of IDs. """ parsed_html: BeautifulSoup = BeautifulSoup( - markup=await fetch_url_content_with_session(MEMBERS_LIST_URL), features="html.parser" + markup=await fetch_url_content_with_session(_members_list_url()), + features="html.parser", ) member_ids: set[int] = set() From ffeab58d0fca7787fc945bdee3e75a4e6ba3a8e9 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:16:55 +0100 Subject: [PATCH 29/33] Bump dependencies --- uv.lock | 345 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 183 insertions(+), 162 deletions(-) diff --git a/uv.lock b/uv.lock index 254a6a336..1d9050dae 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -24,31 +24,31 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, ] [[package]] @@ -111,27 +111,46 @@ wheels = [ [[package]] name = "ast-serialize" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, - { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, - { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, - { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, - { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, - { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, - { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, - { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, - { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, - { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, ] [[package]] @@ -207,11 +226,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] @@ -293,26 +312,26 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.2" +version = "7.15.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, - { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, - { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, - { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, ] [[package]] @@ -338,21 +357,21 @@ wheels = [ [[package]] name = "django" -version = "6.0.7" +version = "6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/42/6cb20996733984c1f6661daeda3877990836c76c633c6c8879d39f7120eb/django-6.1.tar.gz", hash = "sha256:86a2aacd59b817e4d6ac2ebfe22356c58f66f7b24e503f71b7c2fead677ee48b", size = 11223034, upload-time = "2026-08-05T19:21:53.789Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/ec/1ce5334b6a2c52ce619c23a0be8d366a57a0e080ebb2d88266e5c849157c/django-6.0.7-py3-none-any.whl", hash = "sha256:a037427c2288443a8c02a1b02295a31c239663aa682bc50b1976afb7cf6a769e", size = 8373344, upload-time = "2026-07-07T13:51:20.007Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/ce847620134cfab903e75690c498af73b46abbede2912ea89bd76d5c1e76/django-6.1-py3-none-any.whl", hash = "sha256:6c132cd980c9392b06807d4ca52d72530d631dc65a85d9dacede00a780cefbbe", size = 8417399, upload-time = "2026-08-05T19:21:47.285Z" }, ] [[package]] name = "django-stubs" -version = "6.0.7" +version = "6.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -360,9 +379,9 @@ dependencies = [ { name = "types-pyyaml" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/90/087c6e424e705e05182e543ef6b366a59eb5c92ab008b0dbbba55f357a40/django_stubs-6.0.7.tar.gz", hash = "sha256:bc55431c0af745a64e39cf33a8d36c87dccbedeae2fe26fab47dd355270e8538", size = 282293, upload-time = "2026-07-14T10:08:27.122Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/9f/6ee6cdb64c39dd1f4fdba9f54357cbab6cd649e1bc9bfb4af04f84a082d5/django_stubs-6.0.8.tar.gz", hash = "sha256:e4b8472e4bd38d4d3e327494346167c94021ac63ee556fe7efbdab5cd0bccd99", size = 288400, upload-time = "2026-08-06T11:05:08.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/86/230ae6056221b543d63f7710d73967fe1c6840693540e99ea3c54f45859c/django_stubs-6.0.7-py3-none-any.whl", hash = "sha256:7ed9a14c438e589272ca04e966dee82a4d1ff7ca5c2171bc986c50a0d03ec35b", size = 547460, upload-time = "2026-07-14T10:08:25.626Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/a952ac7d1afc8c4f2deb837c0d3663cc534965de3907e0ccc63c9d011c14/django_stubs-6.0.8-py3-none-any.whl", hash = "sha256:cac775d239911c232f9fe46e331385cf7dbb12ca1ba8aca165ad12954b25c433", size = 549504, upload-time = "2026-08-06T11:05:07.323Z" }, ] [package.optional-dependencies] @@ -372,15 +391,15 @@ compatible-mypy = [ [[package]] name = "django-stubs-ext" -version = "6.0.7" +version = "6.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/50/917f7224ea470e89cdcdc93d3dfe75b8391adf976cf12f2ecdb5f5d122be/django_stubs_ext-6.0.7.tar.gz", hash = "sha256:c3172c5126614fd2a44d0196b313b44c21f717cb09477ba52b447d41f4ce613e", size = 6665, upload-time = "2026-07-14T10:07:56.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/54/79c06f12606ca1d22510900a251aeee92b9d91f80385d62c0c493d54f00e/django_stubs_ext-6.0.8.tar.gz", hash = "sha256:a64c332fc2f907b3bfeed38f1751eff1cbb377873c834d875b4cb0fdb63a3782", size = 6843, upload-time = "2026-08-06T11:04:30.701Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/65/4d73fce956b5ebf26449259664360e539fbe95f0e86be396c28b636ba72a/django_stubs_ext-6.0.7-py3-none-any.whl", hash = "sha256:53a9c7c5a7c7e718cc6308cfce1e7470f2cac0b9d38dbcd60fbfa82704f1d592", size = 10362, upload-time = "2026-07-14T10:07:55.653Z" }, + { url = "https://files.pythonhosted.org/packages/e5/52/dd438b231490a6be853446b0bf32ec8a011ff4e386e7f8813659a2830664/django_stubs_ext-6.0.8-py3-none-any.whl", hash = "sha256:2214729fd9ebdc1c6ea4dde939db4c4b4b827473926dfe228f1588f7f637a9a2", size = 10401, upload-time = "2026-08-06T11:04:29.602Z" }, ] [[package]] @@ -507,24 +526,26 @@ wheels = [ [[package]] name = "librt" -version = "0.13.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, - { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, - { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, - { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, - { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, - { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, - { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, - { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, - { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, - { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, ] [[package]] @@ -671,11 +692,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -727,26 +748,26 @@ wheels = [ [[package]] name = "prek" -version = "0.4.10" +version = "0.4.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/54/edc21e275f9fa3540d4d98cf349c2de11621d6729cc401bb7aedf563609e/prek-0.4.10.tar.gz", hash = "sha256:db3122f4e780eb4587635e6a83df881caf2dbb1eb7799d1cca51158216d6f33b", size = 502565, upload-time = "2026-07-16T10:13:00.788Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/5c/cb6e63f7e5a58a5313ddb70409174f4dc004e4b0910b8a8d3f59b2225a95/prek-0.4.12.tar.gz", hash = "sha256:04beeba7f40437cd2f36804b84101bd7f3c9fb40b52da46a25604642ab2bfb09", size = 519080, upload-time = "2026-08-03T11:28:33.147Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/e7/5a63528ba7b95b64f38db3e253aed49ee8e5e8ba16589889d2b7f809edb7/prek-0.4.10-py3-none-linux_armv6l.whl", hash = "sha256:023f302741d79301346c3088ba43a9592aff0ecdbe5ddc3019fa9b1183319c5e", size = 5694609, upload-time = "2026-07-16T10:12:26.352Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ef/ee9e6bf9a5ce242e9e4e66ac4e2e9042a0f6fd9f367cee18ad404456e93d/prek-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:72adc707e16f97564bbae08d22b222ac3bb2491f8fbfb5a0754f80d472c28a71", size = 6044037, upload-time = "2026-07-16T10:12:28.539Z" }, - { url = "https://files.pythonhosted.org/packages/68/7e/da08cc39e5348ccb9234e63a21ee56861f72e8497d6a78f0db1ccae6515d/prek-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:04c9321957e1b32e1fc7cf60bb4f90bba3761f8659d5551ed04f96e25596de49", size = 5535983, upload-time = "2026-07-16T10:12:30.691Z" }, - { url = "https://files.pythonhosted.org/packages/30/c6/0486a35bb687a9beac7a5810bd1104c6da56d469b30b1eeaeefd03c99da2/prek-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e66ccf6c5e4ebadd05cd98cb338d7f553e4d27aa243cf91279c5a569b3cdccc7", size = 5862085, upload-time = "2026-07-16T10:12:33.042Z" }, - { url = "https://files.pythonhosted.org/packages/52/39/277fe17ae1f121e532e3942456f5a6d01ddacfbc550e481dcb359be7a1b0/prek-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63f9061d75a50ef0ca92c4b596ad352937a845df80758244950e513b27e9e18f", size = 5605697, upload-time = "2026-07-16T10:12:35.498Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a1/08354af3e000f2656fad086690d834eab6c04631ff41313a219ea6232199/prek-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c2ff7110e4bfaafbbab13c2893a337081aca61ed797f14b6b224d2ea9741eef", size = 6034111, upload-time = "2026-07-16T10:12:37.545Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/4702396c8d486132e5ce009ab56a0b37f50cb6866830d371f2617b7bdfdc/prek-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b696a05542e79aa27bcce68d1792e77f4fe6f9c6b012b34d74d62f964f3c72d", size = 6787203, upload-time = "2026-07-16T10:12:40.031Z" }, - { url = "https://files.pythonhosted.org/packages/90/29/b5d5d6fb87ebd64b37471e3e79761de9983f85e14d69c522efe7af6620ce/prek-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:431b44d6054e72815b4b05e1173596dfd02a7f7461211d40a2e3117e414642ad", size = 6261333, upload-time = "2026-07-16T10:12:42.216Z" }, - { url = "https://files.pythonhosted.org/packages/94/d6/54ba696d19f7efdc184093353cce713a850aef9c3556e23faeecafa22e94/prek-0.4.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ccbd2b4fd1df790087ba18b4506f680471922a5f13714f19801568434a040dee", size = 5867761, upload-time = "2026-07-16T10:12:44.329Z" }, - { url = "https://files.pythonhosted.org/packages/be/7d/3975098aa2baaabfc10f99f9fcf78045c4f10851beed8e9812b6a2688eab/prek-0.4.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:479e7480b447191aa5c6ed67e80f081d0f5ee4e878b140f4d2cee44165395f1c", size = 5714412, upload-time = "2026-07-16T10:12:46.297Z" }, - { url = "https://files.pythonhosted.org/packages/97/c0/3e0aac190fe95fdef98526343559b61d4d9fd54444c8c9137ba02412afe1/prek-0.4.10-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0bb7451025cbd2b68e480a13cf665d7a5c87c8b87bf18549a78985c17df817ed", size = 5578145, upload-time = "2026-07-16T10:12:48.261Z" }, - { url = "https://files.pythonhosted.org/packages/d7/44/7b26035534204b8b8a9d5e625479201e616413d287262f557cb32e1f8d77/prek-0.4.10-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4fb047e5776676805794574b2d7b178cb3ab536793aadf172419fcda56b34a57", size = 5889245, upload-time = "2026-07-16T10:12:50.818Z" }, - { url = "https://files.pythonhosted.org/packages/7e/6c/178a9d768876b4211a1bf63907fe308ae02d173639bcf41cea3c5eed35c1/prek-0.4.10-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:08318818d19caf79643babb89f872c92fda134a622b4731df1d6ed61e29d2d26", size = 6372849, upload-time = "2026-07-16T10:12:52.952Z" }, - { url = "https://files.pythonhosted.org/packages/4d/84/d5f5ac8193602883f9dd1d675d9d4084e34fbe3ed2ef50a0c336d8a53d8f/prek-0.4.10-py3-none-win32.whl", hash = "sha256:092872714dcde480a662bbdd98b980b248c2d3e10543d4d53a3a58cc9e5b35b0", size = 5413005, upload-time = "2026-07-16T10:12:55.113Z" }, - { url = "https://files.pythonhosted.org/packages/41/63/9e648fda10bc02c9b6ba305f93b6a6e4fd37d23d13a269a9d2d6bb44eaa1/prek-0.4.10-py3-none-win_amd64.whl", hash = "sha256:3d323a18d0f8c50e474a8fa29fb93bd2db680116d8afb19b76e72ad4667f58e6", size = 5799075, upload-time = "2026-07-16T10:12:56.963Z" }, - { url = "https://files.pythonhosted.org/packages/22/74/b34d8c80cec8dccc7b922c75b9dca62b18b603b5ed2eea93c9d7c2928d2d/prek-0.4.10-py3-none-win_arm64.whl", hash = "sha256:5e93865ef96756c4a26f37ece04ad514abbc19ae6a23ed1a507b6314e6a0d2fb", size = 5563955, upload-time = "2026-07-16T10:12:59.07Z" }, + { url = "https://files.pythonhosted.org/packages/f3/23/5811a3161e072e5f93e4da01af611ee30c32922507b8ab4d9873df6affd3/prek-0.4.12-py3-none-linux_armv6l.whl", hash = "sha256:cd92000b051e433f26340821cf1cc8e6e3960f1275f3d516ca01f05905abba64", size = 5793226, upload-time = "2026-08-03T11:28:09.534Z" }, + { url = "https://files.pythonhosted.org/packages/a3/88/8607845d94eb1482e1bd335dadf098618f077a15775f7e98de99669052b4/prek-0.4.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5904fe6c6ab26e7d8792a3c7f1e3fc8d94fcfb63ad33b247c35f004b62cb6275", size = 6132269, upload-time = "2026-08-03T11:28:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ac/28/571d79ba457fbd9ecf40ae879c91952e12f5fa475306218c91139b86db7a/prek-0.4.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:df3eff1db9c24dc293010a07bc7a0ae0c541d55af828f5586405dedc28c4920d", size = 5614964, upload-time = "2026-08-03T11:28:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a9/3f5cb79a73c764a8ac38d5bcd51e0df57239856eca7949b09bdac4338bf3/prek-0.4.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c7733b44ca772ea32ec6a8bee669d0358bdf45873e79767afed196065084f31c", size = 5941047, upload-time = "2026-08-03T11:28:14.45Z" }, + { url = "https://files.pythonhosted.org/packages/8c/00/1dfed0ef8af10c5c32aa903486dccd33d2df171f3d945a037c5692f10760/prek-0.4.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87f170cf1ffd6e3a196f947b83dff1f6c2cd68635f8d49740278bebe7b682262", size = 5707994, upload-time = "2026-08-03T11:28:15.914Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bd/5f388f6cbdc0445b850e7c1a160d0be67fcef8bf221e3c8141a1feccef17/prek-0.4.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57dad513831f060cf73808df8edec29d46ec311435aa69f21c80edebf23dc5e1", size = 6133784, upload-time = "2026-08-03T11:28:17.184Z" }, + { url = "https://files.pythonhosted.org/packages/ba/47/342091a987bf68a74acec6d226a40ce7d51faf0019aa4126cc7bc952f8a7/prek-0.4.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b204844abc7ded983471f576ae8dc13b99e9b8d022e4d4b46176c6654769c9d8", size = 6901589, upload-time = "2026-08-03T11:28:18.545Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/3ef7bdc3c3441649ebc040b9e164a13163e1e5fabae23e7bbb901992f3de/prek-0.4.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43b0a5a9d3f2f77871fdcb7893bfc5c8fe7e44f4e603ce6e4712bfec96b2d6f2", size = 6342189, upload-time = "2026-08-03T11:28:20Z" }, + { url = "https://files.pythonhosted.org/packages/c4/da/6277908442301b1b92a2879f6b04aaa03accb900f80e42776fc28b8197ef/prek-0.4.12-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0d188e572c306cc44b96e1bae5647e25b7bd311113f3f3f4a67320c257ee64a3", size = 5951250, upload-time = "2026-08-03T11:28:21.339Z" }, + { url = "https://files.pythonhosted.org/packages/a3/68/bff51a7332837edb1ecbe017325adb7fafd69b9c7828ddc81a1334b884af/prek-0.4.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:986f52d104b7066190f0f32aebe3467710356de265e9bfd892101ba99371db4d", size = 5804147, upload-time = "2026-08-03T11:28:22.656Z" }, + { url = "https://files.pythonhosted.org/packages/aa/de/b7f544971072ed7814125145dfeb1f7c15cce6b78ccea65a96298ff37838/prek-0.4.12-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:13e34d9e09bafcbf1f25a01cf86985e2c5e486591d3f45b2786ba3de82e5153a", size = 5680104, upload-time = "2026-08-03T11:28:24.271Z" }, + { url = "https://files.pythonhosted.org/packages/68/94/95942bcc20a6a91ec2989aa30fdeb00ad095be736ec48b4bbcf0376166b1/prek-0.4.12-py3-none-musllinux_1_1_i686.whl", hash = "sha256:3d0208370da73e8b5bc97f2492dc3975f8dd2c22f4bf6e1f2cf3342503764b52", size = 5975030, upload-time = "2026-08-03T11:28:25.683Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/26e6497198d81cf9aa82495400aef46adea8df3e4a4efc5f00e3b6ab3292/prek-0.4.12-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b1005f42920111bec1403c25e8f2f12ec7af0be06686cc3b8dcf85429af908a8", size = 6458532, upload-time = "2026-08-03T11:28:27.121Z" }, + { url = "https://files.pythonhosted.org/packages/44/02/ee140c2eb4701bd194db429d84630733492be94897d5f72b61d6f11e6619/prek-0.4.12-py3-none-win32.whl", hash = "sha256:afee229488dcceaea282288e4d7096a93da5a8b85649d9ef506dbdbcd78f38a7", size = 5502213, upload-time = "2026-08-03T11:28:28.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/744cff84def48c1ce38c0b4f643a3553c66976c5bb7869ab7317044870e4/prek-0.4.12-py3-none-win_amd64.whl", hash = "sha256:fdd27bad8adafea8fe77606950ca09200d59296a47ab131cfb88718d460949d7", size = 5868065, upload-time = "2026-08-03T11:28:30.377Z" }, + { url = "https://files.pythonhosted.org/packages/46/1d/e2c0fc222904ef73df1739b11a83edc29e38bc4bc61259f2ca6d2f15abb0/prek-0.4.12-py3-none-win_arm64.whl", hash = "sha256:45e34a24fba4a4e4568682477158591698efc2375b8d1d418ae424691c4bd01b", size = 5632819, upload-time = "2026-08-03T11:28:31.743Z" }, ] [[package]] @@ -1046,27 +1067,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, ] [[package]] @@ -1089,11 +1110,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.9" +version = "2.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, ] [[package]] @@ -1264,11 +1285,11 @@ wheels = [ [[package]] name = "types-pyyaml" -version = "6.0.12.20260518" +version = "6.0.12.20260724" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, + { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" }, ] [[package]] @@ -1339,31 +1360,31 @@ wheels = [ [[package]] name = "yarl" -version = "1.24.2" +version = "1.24.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, - { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, - { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, - { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, - { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, - { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, - { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, ] From 1c7b402a09cbbfd9f4cb7945cbb73d5fe5656596 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:48:09 +0100 Subject: [PATCH 30/33] Improve exception handling --- cogs/config.py | 70 +++++++++++++++++--------------- config/__init__.py | 14 ++++--- config/_accessor.py | 39 +++++++----------- config/_document.py | 12 +----- config/_editor.py | 34 ++++------------ exceptions/__init__.py | 14 +++++++ exceptions/settings.py | 81 +++++++++++++++++++++++++++++++++++++ tests/config/test_schema.py | 4 +- utils/tex_bot.py | 4 +- 9 files changed, 168 insertions(+), 104 deletions(-) create mode 100644 exceptions/settings.py diff --git a/cogs/config.py b/cogs/config.py index 3a943c53e..2305bbb61 100644 --- a/cogs/config.py +++ b/cogs/config.py @@ -107,6 +107,40 @@ def _format_restart_required_warning(restart_required_settings: "AbstractSet[str ) +async def _autocomplete_get_settings_names( + ctx: "TeXBotAutocompleteContext", +) -> "Sequence[discord.OptionChoice]": + """ + Autocomplete callable that generates the configurable settings names, in order. + + Every setting whose name contains what has been typed so far is suggested, + rather than only those beginning with it, because each name is prefixed with the + section holding it & so is rarely typed from its beginning. + + NOTE: Returned as a sequence, rather than the set that every other autocomplete + callable returns, because these suggestions are alphabetically ordered & a set + would discard that ordering. + + NOTE: Defined at module level, rather than as a staticmethod, because Pycord + fails to recognise a staticmethod referenced by its bare name from within its + own class body as a coroutine function, so never awaits the result. + """ + TYPED_VALUE: Final[str] = str(ctx.value or "").strip().lower() + + MATCHING_SETTINGS_NAMES: Final[Sequence[str]] = [ + setting_name + for setting_name in config.documented_setting_names() + if TYPED_VALUE in setting_name.lower() + ] + + return [ + discord.OptionChoice(name=setting_name, value=setting_name) + # NOTE: Sliced because Discord refuses a response holding more suggestions + # than it is willing to display. + for setting_name in MATCHING_SETTINGS_NAMES[:MAXIMUM_AUTOCOMPLETE_SUGGESTIONS] + ] + + class ConfigCommandsCog(TeXBotBaseCog): """Cog class that defines the "/config" command group & its call-back methods.""" @@ -115,36 +149,6 @@ class ConfigCommandsCog(TeXBotBaseCog): description="View & change TeX-Bot's configuration.", ) - @staticmethod - async def autocomplete_get_settings_names( - ctx: "TeXBotAutocompleteContext", - ) -> "Sequence[discord.OptionChoice]": - """ - Autocomplete callable that generates the configurable settings names, in order. - - Every setting whose name contains what has been typed so far is suggested, - rather than only those beginning with it, because each name is prefixed with the - section holding it & so is rarely typed from its beginning. - - NOTE: Returned as a sequence, rather than the set that every other autocomplete - callable returns, because these suggestions are alphabetically ordered & a set - would discard that ordering. - """ - TYPED_VALUE: Final[str] = str(ctx.value or "").strip().lower() - - MATCHING_SETTINGS_NAMES: Final[Sequence[str]] = [ - setting_name - for setting_name in config.documented_setting_names() - if TYPED_VALUE in setting_name.lower() - ] - - return [ - discord.OptionChoice(name=setting_name, value=setting_name) - # NOTE: Sliced because Discord refuses a response holding more suggestions - # than it is willing to display. - for setting_name in MATCHING_SETTINGS_NAMES[:MAXIMUM_AUTOCOMPLETE_SUGGESTIONS] - ] - @config.command( name="reload", description="Reload the configuration file, applying any changes made to it.", @@ -229,7 +233,7 @@ async def reload(self, ctx: "TeXBotApplicationContext") -> None: name="setting", description="The name of the setting to show.", input_type=str, - autocomplete=autocomplete_get_settings_names, + autocomplete=_autocomplete_get_settings_names, required=True, parameter_name="setting_name", ) @@ -304,7 +308,7 @@ async def get(self, ctx: "TeXBotApplicationContext", setting_name: str) -> None: name="setting", description="The name of the setting to change.", input_type=str, - autocomplete=autocomplete_get_settings_names, + autocomplete=_autocomplete_get_settings_names, required=True, parameter_name="setting_name", ) @@ -377,7 +381,7 @@ async def set( name="setting", description="The name of the setting to return to its default value.", input_type=str, - autocomplete=autocomplete_get_settings_names, + autocomplete=_autocomplete_get_settings_names, required=True, parameter_name="setting_name", ) diff --git a/config/__init__.py b/config/__init__.py index ddbb62d39..4811b720f 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -11,17 +11,19 @@ import logging from typing import TYPE_CHECKING, NamedTuple -from ._accessor import SettingsAccessor, SettingsNotLoadedError, SettingsValidationError -from ._document import ( +from exceptions import ( InvalidSettingsFileError, - SettingsDocument, + SettingsFileChangedError, SettingsFileNotFoundError, - get_settings_file_path, + SettingsNotLoadedError, + SettingsValidationError, + UnknownSettingError, ) + +from ._accessor import SettingsAccessor +from ._document import SettingsDocument, get_settings_file_path from ._editor import ( SETTING_NAME_SEPARATOR, - SettingsFileChangedError, - UnknownSettingError, documented_setting_names, format_file_difference, format_setting_value, diff --git a/config/_accessor.py b/config/_accessor.py index c8abfa78f..0b839ecc6 100644 --- a/config/_accessor.py +++ b/config/_accessor.py @@ -12,7 +12,14 @@ from pydantic import BaseModel, ValidationError -from ._document import InvalidSettingsFileError, SettingsDocument, SettingsFileNotFoundError +from exceptions import ( + InvalidSettingsFileError, + SettingsFileNotFoundError, + SettingsNotLoadedError, + SettingsValidationError, +) + +from ._document import SettingsDocument from ._schema import SettingsSchema, nested_settings_model_of, setting_names_within if TYPE_CHECKING: @@ -32,25 +39,7 @@ ) -__all__: "Sequence[str]" = ( - "SettingsAccessor", - "SettingsNotLoadedError", - "SettingsValidationError", -) - - -class SettingsNotLoadedError(Exception): - """Exception class to raise when configuration is accessed before it has been loaded.""" - - def __init__(self, message: str | None = None) -> None: - """Initialise a new SettingsNotLoadedError with the given message.""" - super().__init__( - message or "Configuration cannot be accessed before it has been loaded." - ) - - -class SettingsValidationError(Exception): - """Exception class to raise when the configuration file contains invalid settings.""" +__all__: "Sequence[str]" = ("SettingsAccessor",) @dataclass(frozen=True, slots=True) @@ -69,7 +58,7 @@ class _LoadedSettings: _MISSING: "Final[object]" = object() -def _flatten_settings(model: BaseModel, prefix: str = "") -> "Mapping[str, object]": +def flatten_settings(model: BaseModel, prefix: str = "") -> "Mapping[str, object]": """ Flatten a settings model into a mapping of colon-separated key paths to values. @@ -86,7 +75,7 @@ def _flatten_settings(model: BaseModel, prefix: str = "") -> "Mapping[str, objec KEY_PATH: str = f"{prefix}{field_name.replace('_', '-')}" if isinstance(value, BaseModel): - flattened_settings.update(_flatten_settings(value, prefix=f"{KEY_PATH}:")) + flattened_settings.update(flatten_settings(value, prefix=f"{KEY_PATH}:")) continue NESTED_MODEL: type[BaseModel] | None = nested_settings_model_of(field) @@ -185,9 +174,9 @@ def reload(self, file_path: "Path | None" = None) -> "AbstractSet[str]": ) from validation_error PREVIOUS_SETTINGS: Final[Mapping[str, object]] = ( - {} if self._loaded is None else _flatten_settings(self._loaded.snapshot) + {} if self._loaded is None else flatten_settings(self._loaded.snapshot) ) - NEW_SETTINGS: Final[Mapping[str, object]] = _flatten_settings(snapshot) + NEW_SETTINGS: Final[Mapping[str, object]] = flatten_settings(snapshot) # NOTE: Rebinding this single reference is what makes a reload atomic: every # reader either sees the whole of the previous configuration, or the whole of the @@ -208,7 +197,7 @@ def as_flat_mapping(self) -> "Mapping[str, object]": Used by the `/config` command to view settings by name. """ - return _flatten_settings(self._current.snapshot) + return flatten_settings(self._current.snapshot) @property def logging(self) -> "LoggingSettings": diff --git a/config/_document.py b/config/_document.py index f4d77d929..2d4f963d7 100644 --- a/config/_document.py +++ b/config/_document.py @@ -20,6 +20,8 @@ from ruamel.yaml.comments import CommentedMap from ruamel.yaml.error import YAMLError +from exceptions import InvalidSettingsFileError, SettingsFileNotFoundError + if TYPE_CHECKING: from collections.abc import Iterator, Sequence from logging import Logger @@ -30,9 +32,7 @@ __all__: "Sequence[str]" = ( "SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME", - "InvalidSettingsFileError", "SettingsDocument", - "SettingsFileNotFoundError", "get_settings_file_path", ) @@ -46,14 +46,6 @@ SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME: "Final[str]" = "TEX_BOT_CONFIG_PATH" -class SettingsFileNotFoundError(Exception): - """Exception class to raise when no deployment configuration file could be located.""" - - -class InvalidSettingsFileError(Exception): - """Exception class to raise when the deployment configuration file could not be read.""" - - def get_settings_file_path() -> Path: """ Locate the deployment configuration file. diff --git a/config/_editor.py b/config/_editor.py index 307a1f1f0..db526345b 100644 --- a/config/_editor.py +++ b/config/_editor.py @@ -20,12 +20,16 @@ from ruamel.yaml import YAML from ruamel.yaml.error import YAMLError -from ._accessor import SettingsValidationError, _flatten_settings -from ._document import ( +from exceptions import ( InvalidSettingsFileError, - SettingsDocument, + SettingsFileChangedError, SettingsFileNotFoundError, + SettingsValidationError, + UnknownSettingError, ) + +from ._accessor import flatten_settings +from ._document import SettingsDocument from ._schema import SettingsSchema, get_settings_metadata if TYPE_CHECKING: @@ -38,8 +42,6 @@ __all__: "Sequence[str]" = ( "SETTING_NAME_SEPARATOR", - "SettingsFileChangedError", - "UnknownSettingError", "documented_setting_names", "format_file_difference", "format_setting_value", @@ -61,26 +63,6 @@ _NULL_LITERALS: "Final[frozenset[str]]" = frozenset({"null", "~"}) -class UnknownSettingError(Exception): - """Exception class to raise when a setting that the schema does not declare is named.""" - - def __init__(self, setting_name: str) -> None: - """Initialise a new UnknownSettingError for the given setting name.""" - self.setting_name: str = setting_name - - super().__init__(f"No configuration setting is named {setting_name!r}.") - - -class SettingsFileChangedError(Exception): - """Exception class to raise when the configuration file has been edited by hand.""" - - def __init__(self) -> None: - """Initialise a new SettingsFileChangedError.""" - super().__init__( - "The configuration file has been changed since TeX-Bot last loaded it." - ) - - def documented_setting_names() -> "Sequence[str]": """Return the name of every setting that can be viewed or changed, in order.""" return sorted(get_settings_metadata()) @@ -220,7 +202,7 @@ def format_file_difference( """ file_error: Exception try: - FILE_VALUES: Final[Mapping[str, object]] = _flatten_settings( + FILE_VALUES: Final[Mapping[str, object]] = flatten_settings( _snapshot_of(SettingsDocument.load(file_path)) ) except ( diff --git a/exceptions/__init__.py b/exceptions/__init__.py index e95eca01b..3b506cd16 100644 --- a/exceptions/__init__.py +++ b/exceptions/__init__.py @@ -28,6 +28,14 @@ MessagesJSONFileValueError, ) from .msl import MSLMembershipError +from .settings import ( + InvalidSettingsFileError, + SettingsFileChangedError, + SettingsFileNotFoundError, + SettingsNotLoadedError, + SettingsValidationError, + UnknownSettingError, +) from .strike import NoAuditLogsStrikeTrackingError, StrikeTrackingError if TYPE_CHECKING: @@ -48,6 +56,7 @@ "InvalidActionDescriptionError", "InvalidActionTargetError", "InvalidMessagesJSONFileError", + "InvalidSettingsFileError", "MSLMembershipError", "MemberRoleDoesNotExistError", "MessagesJSONFileMissingKeyError", @@ -57,5 +66,10 @@ "RoleDoesNotExistError", "RolesChannelDoesNotExistError", "RulesChannelDoesNotExistError", + "SettingsFileChangedError", + "SettingsFileNotFoundError", + "SettingsNotLoadedError", + "SettingsValidationError", "StrikeTrackingError", + "UnknownSettingError", ) diff --git a/exceptions/settings.py b/exceptions/settings.py new file mode 100644 index 000000000..9a52569c6 --- /dev/null +++ b/exceptions/settings.py @@ -0,0 +1,81 @@ +"""Custom exception classes related to reading & changing the deployment configuration.""" + +from typing import TYPE_CHECKING, override + +from typed_classproperties import classproperty + +from .base import BaseTeXBotError +from .config_changes import ImproperlyConfiguredError + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__: "Sequence[str]" = ( + "InvalidSettingsFileError", + "SettingsFileChangedError", + "SettingsFileNotFoundError", + "SettingsNotLoadedError", + "SettingsValidationError", + "UnknownSettingError", +) + + +class SettingsFileNotFoundError(ImproperlyConfiguredError): + """Exception class to raise when no deployment configuration file could be located.""" + + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + return "No deployment configuration file could be located." + + +class InvalidSettingsFileError(ImproperlyConfiguredError): + """Exception class to raise when the deployment configuration file could not be read.""" + + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + return "The deployment configuration file could not be read." + + +class SettingsNotLoadedError(BaseTeXBotError, Exception): + """Exception class to raise when configuration is accessed before it has been loaded.""" + + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + return "Configuration cannot be accessed before it has been loaded." + + +class SettingsValidationError(ImproperlyConfiguredError): + """Exception class to raise when the configuration file contains invalid settings.""" + + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + return "The configuration file contains invalid settings." + + +class UnknownSettingError(BaseTeXBotError, Exception): + """Exception class to raise when a setting that the schema does not declare is named.""" + + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + return "No configuration setting is named that." + + @override + def __init__(self, setting_name: str) -> None: + """Initialise a new UnknownSettingError for the given setting name.""" + self.setting_name: str = setting_name + + super().__init__(f"No configuration setting is named {setting_name!r}.") + + +class SettingsFileChangedError(BaseTeXBotError, Exception): + """Exception class to raise when the configuration file has been edited by hand.""" + + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + return "The configuration file has been changed since TeX-Bot last loaded it." diff --git a/tests/config/test_schema.py b/tests/config/test_schema.py index 91b337533..64d7a93c0 100644 --- a/tests/config/test_schema.py +++ b/tests/config/test_schema.py @@ -603,10 +603,10 @@ def test_every_secret_setting_is_flagged() -> None: @staticmethod def test_metadata_covers_every_configurable_setting() -> None: """Test that the metadata describes exactly the settings that can be configured.""" - from config._accessor import _flatten_settings # noqa: PLC0415 + from config._accessor import flatten_settings # noqa: PLC0415 SETTINGS_NAMES: Final[frozenset[str]] = frozenset( - _flatten_settings(SettingsSchema.model_validate(_config())) + flatten_settings(SettingsSchema.model_validate(_config())) ) # NOTE: Every flattened key path must name a setting that `/config` recognises, diff --git a/utils/tex_bot.py b/utils/tex_bot.py index 8e445dad9..5961a8e4a 100644 --- a/utils/tex_bot.py +++ b/utils/tex_bot.py @@ -494,13 +494,13 @@ async def fetch_log_channel(self) -> discord.TextChannel: """ Retrieve the Discord log channel. - If no DISCORD_LOG_CHANNEL_WEBHOOK_URL is specified, + If no `logging:discord-channel:webhook-url` is specified, a ValueError exception will be raised. """ if settings.logging.discord_channel is None: NO_LOG_CHANNEL_MESSAGE: Final[str] = ( "Cannot fetch log channel, " - "when no DISCORD_LOG_CHANNEL_WEBHOOK_URL has been set." + "when no logging:discord-channel:webhook-url has been set." ) raise ValueError(NO_LOG_CHANNEL_MESSAGE) From df6a0a3a5f11eba49828a1625e1a4b1b30003d0a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:26:29 +0000 Subject: [PATCH 31/33] [autofix.ci] apply automated fixes --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf92e6fab..600839f95 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -117,7 +117,7 @@ repos: args: [--autofix] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.22 + rev: v0.16.1 hooks: - id: ruff-check args: [--fix] From 0df4e95b42d6d7d678ebff4bab6ec6dc206cc15d Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:54:30 +0100 Subject: [PATCH 32/33] Fix CI Issues --- .pre-commit-config.yaml | 1 - .yamllint.yaml | 4 +- cogs/make_member.py | 4 +- config/_accessor.py | 24 ++++-- config/_document.py | 3 +- config/_logging.py | 7 +- config/_messages.py | 3 +- config/_schema.py | 2 +- stubs/discord/commands/core.pyi | 4 +- tests/config/test_command.py | 3 +- tests/config/test_document.py | 2 +- tests/config/test_logging.py | 2 +- tests/config/test_schema.py | 2 +- tex-bot-deployment.example.yaml | 127 ++++++++++++++++---------------- 14 files changed, 101 insertions(+), 87 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 600839f95..da1c08917 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -108,7 +108,6 @@ repos: args: [--fix=lf] - id: detect-private-key - id: fix-byte-order-marker - args: [-h] - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.16.0 diff --git a/.yamllint.yaml b/.yamllint.yaml index c5957e39b..fbfe2500c 100644 --- a/.yamllint.yaml +++ b/.yamllint.yaml @@ -5,8 +5,8 @@ extends: default # top down (those that must be filled in first, then each section in turn), which the # alphabetical key ordering required below would destroy. ignore: | - tex-bot-deployment.*.yaml - tex-bot-deployment.yaml + tex-bot-deployment.*.yaml + tex-bot-deployment.yaml locale: en_GB.UTF-8 diff --git a/cogs/make_member.py b/cogs/make_member.py index 6d885fc54..fd3f51be2 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -69,8 +69,8 @@ class MakeMemberCommandCog(TeXBotBaseCog): if ( settings.community_group.full_name and ( - "computer science society" - in settings.community_group.full_name.lower() # noqa: CAR180 + "computer science society" # noqa: CAR180 + in settings.community_group.full_name.lower() or "css" in settings.community_group.full_name.lower() or "uob" in settings.community_group.full_name.lower() or "university of birmingham" diff --git a/config/_accessor.py b/config/_accessor.py index 0b839ecc6..b2eb3b59c 100644 --- a/config/_accessor.py +++ b/config/_accessor.py @@ -7,8 +7,7 @@ applied configuration, even if reloading fails partway through. """ -from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from pydantic import BaseModel, ValidationError @@ -42,17 +41,29 @@ __all__: "Sequence[str]" = ("SettingsAccessor",) -@dataclass(frozen=True, slots=True) class _LoadedSettings: """ A validated configuration snapshot, paired with the document it was parsed from. - Holding both within a single frozen object allows a reload to replace them together, + Holding both within a single immutable object allows a reload to replace them together, by rebinding one reference, so the two can never disagree with one another. """ - snapshot: SettingsSchema - document: SettingsDocument + @override + def __init__(self, snapshot: SettingsSchema, document: SettingsDocument) -> None: + """Initialise a snapshot paired with the document it was parsed from.""" + self._snapshot: SettingsSchema = snapshot + self._document: SettingsDocument = document + + @property + def snapshot(self) -> SettingsSchema: + """The validated settings parsed from the document.""" + return self._snapshot + + @property + def document(self) -> SettingsDocument: + """The comment-preserving document that the snapshot was parsed from.""" + return self._document _MISSING: "Final[object]" = object() @@ -102,6 +113,7 @@ class SettingsAccessor: (for example: `settings.discord.bot_token`). """ + @override def __init__(self) -> None: """Initialise an accessor holding no configuration until it is first loaded.""" self._loaded: _LoadedSettings | None = None diff --git a/config/_document.py b/config/_document.py index 2d4f963d7..2a9e0e3da 100644 --- a/config/_document.py +++ b/config/_document.py @@ -14,7 +14,7 @@ import os import stat from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from ruamel.yaml import YAML from ruamel.yaml.comments import CommentedMap @@ -102,6 +102,7 @@ class SettingsDocument: formatting of the file that a human wrote. """ + @override def __init__(self, file_path: Path, raw: "CommentedMap") -> None: """Initialise a configuration document already parsed from the given file path.""" self._file_path: Path = file_path diff --git a/config/_logging.py b/config/_logging.py index 03a0a7f1f..4ffe58a93 100644 --- a/config/_logging.py +++ b/config/_logging.py @@ -22,9 +22,12 @@ __all__: "Sequence[str]" = ("DISCORD_LOGGER_NAME", "LOGGER_NAME", "apply_logging_settings") -LOGGER_NAME: "Final[str]" = "TeX-Bot" +# NOTE: These hold the *names* of the loggers to retrieve, rather than the loggers +# themselves, so `Final[str]` is the correct annotation despite what CAR201 infers +# from the variable names. +LOGGER_NAME: "Final[str]" = "TeX-Bot" # noqa: CAR201 -DISCORD_LOGGER_NAME: "Final[str]" = "discord" +DISCORD_LOGGER_NAME: "Final[str]" = "discord" # noqa: CAR201 DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME: "Final[str]" = "TeX-Bot" diff --git a/config/_messages.py b/config/_messages.py index 61767c256..b14622333 100644 --- a/config/_messages.py +++ b/config/_messages.py @@ -9,7 +9,7 @@ import json import os from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from exceptions import ( ImproperlyConfiguredError, @@ -98,6 +98,7 @@ def _get_message_set(raw_messages: "Mapping[str, object]", key: str) -> frozense class MessagesAccessor: """Provides access to the response messages that TeX-Bot sends into Discord.""" + @override def __init__(self) -> None: """Initialise an accessor holding no messages until they are first loaded.""" self._welcome_messages: frozenset[str] | None = None diff --git a/config/_schema.py b/config/_schema.py index 6605e41ce..f48ca95b8 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -25,7 +25,7 @@ ConfigDict, Field, HttpUrl, - SecretStr, # noqa: TC002 # NOTE: Pydantic resolves field annotations at runtime + SecretStr, # NOTE: Pydantic resolves field annotations at runtime # noqa: TC002 ) if TYPE_CHECKING: diff --git a/stubs/discord/commands/core.pyi b/stubs/discord/commands/core.pyi index b317daa42..930ea72cb 100644 --- a/stubs/discord/commands/core.pyi +++ b/stubs/discord/commands/core.pyi @@ -20,9 +20,7 @@ from typing import Protocol, overload, override class CommandCallback(Protocol): __name__: str - def __call__( - self, *args: object, **kwargs: object - ) -> Coroutine[object, object, None]: ... + def __call__(self, *args: object, **kwargs: object) -> Coroutine[object, object, None]: ... def slash_command[**P]( *, diff --git a/tests/config/test_command.py b/tests/config/test_command.py index 366bc5f38..77762103c 100644 --- a/tests/config/test_command.py +++ b/tests/config/test_command.py @@ -1,7 +1,7 @@ """Test suite for the "/config" command group.""" import asyncio -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, cast, override import pytest @@ -27,6 +27,7 @@ class _RecordingApplicationContext: """A stand-in for the Discord context that a slash command is invoked with.""" + @override def __init__(self) -> None: """Initialise a context that has been responded to with nothing so far.""" self.responses: list[str] = [] diff --git a/tests/config/test_document.py b/tests/config/test_document.py index 4de442ed6..e39924ddf 100644 --- a/tests/config/test_document.py +++ b/tests/config/test_document.py @@ -167,7 +167,7 @@ class TestWriting: """Test case for persisting the configuration file to disk.""" @staticmethod - def _rejecting_replace(*_args: object, **_kwargs: object) -> None: + def _rejecting_replace(*_args: object, **_kwargs: object) -> None: # noqa: CAR150 """Stand in for a rename that the filesystem refuses.""" raise OSError(16, "Device or resource busy") diff --git a/tests/config/test_logging.py b/tests/config/test_logging.py index 7ed80693a..5ed6dd9f3 100644 --- a/tests/config/test_logging.py +++ b/tests/config/test_logging.py @@ -31,7 +31,7 @@ } -def _logging_settings(**logging_overrides: object) -> "LoggingSettings": +def _logging_settings(**logging_overrides: object) -> "LoggingSettings": # noqa: CAR150 """Build the logging section of a validated configuration.""" return SettingsSchema.model_validate( {**REQUIRED_SETTINGS, "logging": logging_overrides} diff --git a/tests/config/test_schema.py b/tests/config/test_schema.py index 64d7a93c0..333dde0ba 100644 --- a/tests/config/test_schema.py +++ b/tests/config/test_schema.py @@ -32,7 +32,7 @@ } -def _config(**overrides: object) -> "Mapping[str, object]": +def _config(**overrides: object) -> "Mapping[str, object]": # noqa: CAR150 """Build a valid raw configuration, with the given top-level sections replaced.""" return {**REQUIRED_SETTINGS, **overrides} diff --git a/tex-bot-deployment.example.yaml b/tex-bot-deployment.example.yaml index 826e17579..f6af1da83 100644 --- a/tex-bot-deployment.example.yaml +++ b/tex-bot-deployment.example.yaml @@ -1,4 +1,3 @@ ---- # An example TeX-Bot deployment configuration file. # # Copy this file to `tex-bot-deployment.yaml` and fill in the required values. @@ -38,80 +37,80 @@ # reports the line responsible. discord: - # REQUIRED. From your bot's page within the Discord developer portal: - # - bot-token: "" - # REQUIRED. The ID of your community group's main Discord guild. - main-guild-id: 0 + # REQUIRED. From your bot's page within the Discord developer portal: + # + bot-token: "" + # REQUIRED. The ID of your community group's main Discord guild. + main-guild-id: 0 community-group: - # Optional. Falls back to the name of your Discord guild. - # full-name: Computer Science Society - # Optional. Falls back to being derived from the full name. - # short-name: CSS - # Optional. Roles that should only be held by members of your community group. - membership-dependent-roles: [] + # Optional. Falls back to the name of your Discord guild. + # full-name: Computer Science Society + # Optional. Falls back to being derived from the full name. + # short-name: CSS + # Optional. Roles that should only be held by members of your community group. + membership-dependent-roles: [] - # Every link is optional. To set any of them, replace the `{}` below with the - # commented-out block beneath it, keeping only the links you have. - # - # An empty value is not the same as an absent one: every setting that is present must - # hold a valid value, so leave a setting out entirely rather than setting it to "". - links: {} - # links: - # purchase-membership: https://example.com/join - # membership-perks: https://example.com/perks - # moderation-policy: https://example.com/moderation-policy - # # Used in place of an invite link generated by TeX-Bot. - # custom-discord-invite-link: https://discord.gg/example + # Every link is optional. To set any of them, replace the `{}` below with the + # commented-out block beneath it, keeping only the links you have. + # + # An empty value is not the same as an absent one: every setting that is present must + # hold a valid value, so leave a setting out entirely rather than setting it to "". + links: {} + # links: + # purchase-membership: https://example.com/join + # membership-perks: https://example.com/perks + # moderation-policy: https://example.com/moderation-policy + # # Used in place of an invite link generated by TeX-Bot. + # custom-discord-invite-link: https://discord.gg/example - msl: - # Optional. Your community group's organisation ID on your MSL website. - # organisation-id: "1234" - # Optional. Your members-list authentication session cookie, extracted from your - # web-browser after logging in. It is probably named `.AspNet.SharedCookie`. - # auth-cookie: your-authentication-cookie-value - auto-cookie-checking: - enabled: false - interval: 10m + msl: + # Optional. Your community group's organisation ID on your MSL website. + # organisation-id: "1234" + # Optional. Your members-list authentication session cookie, extracted from your + # web-browser after logging in. It is probably named `.AspNet.SharedCookie`. + # auth-cookie: your-authentication-cookie-value + auto-cookie-checking: + enabled: false + interval: 10m # Every section below is optional; the values shown are the defaults. logging: - console: - log-level: INFO - # Omit this section entirely to disable Discord log-channel logging. - # discord-channel: - # webhook-url: https://discord.com/api/webhooks/... - # log-level: WARNING - discord-api: - enabled: false - log-level: INFO - file-name: discord.log + console: + log-level: INFO + # Omit this section entirely to disable Discord log-channel logging. + # discord-channel: + # webhook-url: https://discord.com/api/webhooks/... + # log-level: WARNING + discord-api: + enabled: false + log-level: INFO + file-name: discord.log commands: - ping: - easter-egg-probability: 0.01 - stats: - lookback-days: 30 - displayed-roles: - - Committee - - Member - - Guest - strike: - performed-manually-warning-location: DM - timeout-duration: 1d - reported-message-destination-channel: discord + ping: + easter-egg-probability: 0.01 + stats: + lookback-days: 30 + displayed-roles: + - Committee + - Member + - Guest + strike: + performed-manually-warning-location: DM + timeout-duration: 1d + reported-message-destination-channel: discord reminders: - send-introduction-reminders: - # One of `once`, `interval` or `false`. - enabled: once - delay: 1d16h - interval: 6h - send-get-roles-reminders: - enabled: true - delay: 1d16h - interval: 6h + send-introduction-reminders: + # One of `once`, `interval` or `false`. + enabled: once + delay: 1d16h + interval: 6h + send-get-roles-reminders: + enabled: true + delay: 1d16h + interval: 6h auto-add-committee-to-threads: true From b2985856d7e61bff28aa35fe79d2955c29ed236b Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:29:02 +0100 Subject: [PATCH 33/33] Solve #218 --- cogs/strike.py | 2 +- config/_schema.py | 43 ++++++++++++++++++++++++--- tests/config/test_schema.py | 51 +++++++++++++++++++++++++++++++++ tex-bot-deployment.example.yaml | 6 +++- 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/cogs/strike.py b/cogs/strike.py index b8829f01c..43651d4a5 100644 --- a/cogs/strike.py +++ b/cogs/strike.py @@ -78,7 +78,7 @@ async def perform_moderation_action( if strikes == 1: await strike_user.timeout_for( - datetime.timedelta(hours=24), reason=MODERATION_ACTION_REASON + settings.commands.strike.timeout_duration, reason=MODERATION_ACTION_REASON ) elif strikes == 2: diff --git a/config/_schema.py b/config/_schema.py index f48ca95b8..6476324c7 100644 --- a/config/_schema.py +++ b/config/_schema.py @@ -131,6 +131,11 @@ def _parse_send_introduction_reminders_flag(value: object) -> object: r"(?:(?P(?:\d*\.)?\d+)s)?\Z" ) +# NOTE: Discord will not disable a member's communication more than 28 days into the +# future, & rejects any attempt to do so. +# See . +MAXIMUM_DISCORD_TIMEOUT_DURATION: "Final[datetime.timedelta]" = datetime.timedelta(days=28) + def _parse_time_delta(value: object) -> object: """ @@ -144,7 +149,8 @@ def _parse_time_delta(value: object) -> object: NOTE: A further deviation from that previous implementation: an empty string is rejected rather than silently parsed as a zero-length duration. - Durations that may not be zero-length are declared as `PositiveTimeDelta`; + Durations that may not be zero-length are declared as `PositiveTimeDelta` + (or `DiscordTimeoutDuration`, which is additionally bounded above); this parser itself accepts a zero-length duration written explicitly (`0s`). """ if isinstance(value, datetime.timedelta): @@ -190,7 +196,9 @@ def _ensure_positive_time_delta(value: datetime.timedelta) -> datetime.timedelta Ensure the given duration is longer than zero. A zero-length interval would cause the recurring task looping upon it to run - continuously, without ever pausing between one execution & the next. + continuously, without ever pausing between one execution & the next. A zero-length + moderation timeout would expire the instant it was applied, silently applying no + moderation action at all. """ if value <= datetime.timedelta(0): NON_POSITIVE_TIME_DELTA_MESSAGE: str = ( @@ -201,6 +209,26 @@ def _ensure_positive_time_delta(value: datetime.timedelta) -> datetime.timedelta return value +def _ensure_within_discord_timeout_limit( + value: datetime.timedelta, +) -> datetime.timedelta: + """ + Ensure the given duration is one that Discord will actually apply as a timeout. + + Pycord performs no check of its own before sending the request, so an over-long + duration would otherwise be caught only by Discord itself, surfacing as an opaque + HTTP error at the moment a committee-member tried to apply a strike. + """ + if value > MAXIMUM_DISCORD_TIMEOUT_DURATION: + EXCESSIVE_TIMEOUT_DURATION_MESSAGE: str = ( + "Value should be a duration string describing a duration " + "no longer than 28 days (the longest timeout Discord will apply)" + ) + raise ValueError(EXCESSIVE_TIMEOUT_DURATION_MESSAGE) + + return value + + def _ensure_discord_webhook_url(value: HttpUrl) -> HttpUrl: """Ensure the given URL refers to a Discord webhook.""" if not str(value).startswith("https://discord.com/api/webhooks/"): @@ -243,6 +271,12 @@ def _ensure_unique(value: tuple[str, ...]) -> tuple[str, ...]: BeforeValidator(_parse_time_delta), AfterValidator(_ensure_positive_time_delta), ] +type DiscordTimeoutDuration = Annotated[ + datetime.timedelta, + BeforeValidator(_parse_time_delta), + AfterValidator(_ensure_positive_time_delta), + AfterValidator(_ensure_within_discord_timeout_limit), +] type UniqueStrSequence = Annotated[tuple[str, ...], AfterValidator(_ensure_unique)] type DiscordWebhookURL = Annotated[HttpUrl, AfterValidator(_ensure_discord_webhook_url)] type DiscordSnowflake = Annotated[int, Field(ge=10**16, lt=10**20)] @@ -589,10 +623,11 @@ class StrikeCommandSettings(_BaseSettingsSchema): # type: ignore[explicit-any] ), json_schema_extra={"requires_restart": False, "secret": False}, ) - timeout_duration: TimeDelta = Field( + timeout_duration: DiscordTimeoutDuration = Field( default=datetime.timedelta(hours=24), description=( - "The amount of time to timeout a user for, when using the `/strike` command." + "The amount of time to timeout a user for, when using the `/strike` command.\n" + "Discord will not apply a timeout longer than 28 days." ), json_schema_extra={"requires_restart": False, "secret": False}, ) diff --git a/tests/config/test_schema.py b/tests/config/test_schema.py index 333dde0ba..2b02731ce 100644 --- a/tests/config/test_schema.py +++ b/tests/config/test_schema.py @@ -213,6 +213,57 @@ def test_a_zero_length_delay_is_accepted() -> None: assert settings.reminders.send_get_roles_reminders.delay == datetime.timedelta(0) + @staticmethod + def test_a_zero_length_strike_timeout_is_rejected() -> None: + """ + Test that timing a user out for no time at all is rejected. + + The duration is handed straight to Discord when the first strike is applied, so + a zero-length one would silently apply no moderation action whatsoever. + """ + with pytest.raises(ValidationError, match="longer than zero"): + SettingsSchema.model_validate( + _config(commands={"strike": {"timeout-duration": "0s"}}) + ) + + +class TestDiscordTimeoutLimit: + """Test case for the longest moderation timeout that Discord will apply.""" + + @staticmethod + @pytest.mark.parametrize( + ("raw_duration", "expected_duration"), + ( + ("1s", datetime.timedelta(seconds=1)), + ("28d", datetime.timedelta(days=28)), + ("27d23h59m59s", datetime.timedelta(days=27, hours=23, minutes=59, seconds=59)), + ), + ) + def test_a_duration_within_the_limit_is_accepted( + raw_duration: str, expected_duration: datetime.timedelta + ) -> None: + """Test that a timeout of 28 days or shorter is accepted, the limit included.""" + settings: SettingsSchema = SettingsSchema.model_validate( + _config(commands={"strike": {"timeout-duration": raw_duration}}) + ) + + assert settings.commands.strike.timeout_duration == expected_duration + + @staticmethod + @pytest.mark.parametrize("raw_duration", ("28d0h0m1s", "29d", "365d")) + def test_a_duration_beyond_the_limit_is_rejected(raw_duration: str) -> None: + """ + Test that a timeout longer than 28 days is rejected. + + Discord refuses to disable a member's communication more than 28 days into the + future, & Pycord sends the request without checking, so a duration caught here + would otherwise fail only once a committee-member tried to apply a strike. + """ + with pytest.raises(ValidationError, match="no longer than 28 days"): + SettingsSchema.model_validate( + _config(commands={"strike": {"timeout-duration": raw_duration}}) + ) + class TestValueConstraints: """Test case for the constraints applied to individual settings values.""" diff --git a/tex-bot-deployment.example.yaml b/tex-bot-deployment.example.yaml index f6af1da83..c0fcf3295 100644 --- a/tex-bot-deployment.example.yaml +++ b/tex-bot-deployment.example.yaml @@ -10,7 +10,9 @@ # `dhms`, so `1h30m` and `2d` are both valid. # Every part must carry its unit, so a bare `24` is rejected rather than being read as # 24 seconds. The `interval` of a recurring task must be longer than zero, because a -# task set to repeat every `0s` would run continuously without ever pausing. +# task set to repeat every `0s` would run continuously without ever pausing; the same +# applies to the strike `timeout-duration`, because a `0s` timeout would apply no +# moderation action at all. # # Run `/config reload` after editing this file to apply your changes. # @@ -99,6 +101,8 @@ commands: - Guest strike: performed-manually-warning-location: DM + # How long a first strike times a member out for. + # Discord will not apply a timeout longer than 28 days. timeout-duration: 1d reported-message-destination-channel: discord