diff --git a/.dockerignore b/.dockerignore index b5f57d16e..28fb81b84 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,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/.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/.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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9efceea94..da1c08917 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: @@ -114,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 @@ -123,7 +116,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] diff --git a/.yamllint.yaml b/.yamllint.yaml index cd13ce8a8..fbfe2500c 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/Dockerfile b/Dockerfile index 7e82bf46c..09f283bfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,12 +14,20 @@ 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/ 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 @@ -29,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/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/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/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/config.py b/cogs/config.py new file mode 100644 index 000000000..2305bbb61 --- /dev/null +++ b/cogs/config.py @@ -0,0 +1,521 @@ +"""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 ( + InvalidSettingsFileError, + SettingsFileChangedError, + SettingsFileNotFoundError, + SettingsNotLoadedError, + SettingsValidationError, + UnknownSettingError, +) +from utils import CommandChecks, TeXBotBaseCog + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from collections.abc import Set as AbstractSet + from logging import Logger + from typing import Final + + from config import ConfigReloadResult, ConfigSettingMetadata + from utils import TeXBotApplicationContext, TeXBotAutocompleteContext + + +__all__: "Sequence[str]" = ("ConfigCommandsCog",) + + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + +MAXIMUM_LISTED_SETTINGS: "Final[int]" = 20 + +# 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.""" + 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 + + +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)}" + ) + + +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.""" + + 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 = config.reload_settings() + except SettingsValidationError as configuration_error: + logger.warning("Configuration reload rejected:\n%s", configuration_error) + await ctx.respond( + _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, + ) + return + except ( + SettingsFileNotFoundError, + InvalidSettingsFileError, + OSError, + ) as configuration_error: + logger.warning("Configuration reload failed: %s", configuration_error) + await ctx.respond( + _with_error_details( + ( + ":x: The configuration file could not be read, " + "so no changes have been applied.\n" + ), + configuration_error, + ), + 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 += ( + 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) + # 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 + 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)}" + ) + + # 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, + file_path=config.settings.file_path, + ) + + 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." + ) + + # 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", + 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 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 + 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 SettingsFileChangedError: + await self._respond_with_changed_file(ctx) + return + except SettingsValidationError as validation_error: + await ctx.respond( + _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, + ) + 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_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", + 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( + _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, + ) + + @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( + _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/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..fd3f51be2 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" # 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" + 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..abb4a3705 100644 --- a/cogs/ping.py +++ b/cogs/ping.py @@ -25,9 +25,11 @@ 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["PING_COMMAND_EASTER_EGG_PROBABILITY"], - settings["PING_COMMAND_EASTER_EGG_PROBABILITY"], + 1 - 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..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,33 +41,15 @@ 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"]: - 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"], - 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 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 +58,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 ), ) @@ -111,19 +90,27 @@ 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["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 +119,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..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: @@ -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,8 @@ 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.MODERATION_DOCUMENT_URL}>)\nPlease ensure you have read " + 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." f"{ @@ -442,7 +451,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 +486,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 new file mode 100644 index 000000000..4811b720f --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,177 @@ +""" +Contains settings values and import & setup functions. + +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, NamedTuple + +from exceptions import ( + InvalidSettingsFileError, + SettingsFileChangedError, + SettingsFileNotFoundError, + SettingsNotLoadedError, + SettingsValidationError, + UnknownSettingError, +) + +from ._accessor import SettingsAccessor +from ._document import SettingsDocument, get_settings_file_path +from ._editor import ( + SETTING_NAME_SEPARATOR, + documented_setting_names, + format_file_difference, + 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 + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from collections.abc import Set as AbstractSet + from logging import Logger + from typing import Final + + +__all__: "Sequence[str]" = ( + "SETTING_NAME_SEPARATOR", + "ConfigReloadResult", + "ConfigSettingMetadata", + "InvalidSettingsFileError", + "SettingsDocument", + "SettingsFileChangedError", + "SettingsFileNotFoundError", + "SettingsNotLoadedError", + "SettingsValidationError", + "UnknownSettingError", + "documented_setting_names", + "format_file_difference", + "format_setting_value", + "get_settings_file_path", + "get_settings_metadata", + "messages", + "reload_settings", + "run_setup", + "set_setting", + "setting_metadata", + "settings", + "unset_setting", +) + + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + +settings: "Final[SettingsAccessor]" = SettingsAccessor() + +messages: "Final[MessagesAccessor]" = MessagesAccessor() + + +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 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. + """ + CHANGED_SETTINGS: Final[AbstractSet[str]] = settings.reload() + + if any(changed_setting.startswith("logging:") for changed_setting in CHANGED_SETTINGS): + apply_logging_settings(settings.logging) + + 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 _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. + + 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), _loaded_document() + ) + + 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. 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, _loaded_document() + ) + + 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() + + 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/_accessor.py b/config/_accessor.py new file mode 100644 index 000000000..b2eb3b59c --- /dev/null +++ b/config/_accessor.py @@ -0,0 +1,242 @@ +""" +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 typing import TYPE_CHECKING, override + +from pydantic import BaseModel, ValidationError + +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: + from collections.abc import Mapping, Sequence + from collections.abc import Set as AbstractSet + from pathlib import Path + from typing import Final + + from pydantic.fields import FieldInfo + + from ._schema import ( + CommandsSettings, + CommunityGroupSettings, + DiscordSettings, + LoggingSettings, + RemindersSettings, + ) + + +__all__: "Sequence[str]" = ("SettingsAccessor",) + + +class _LoadedSettings: + """ + A validated configuration snapshot, paired with the document it was parsed from. + + 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. + """ + + @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() + + +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 + 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}:")) + 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 + + +class SettingsAccessor: + """ + Provides access to the validated configuration settings. + + Settings are accessed by section, as attributes + (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 + + @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 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. + + 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 + + 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]": + """ + 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 new file mode 100644 index 000000000..2a9e0e3da --- /dev/null +++ b/config/_document.py @@ -0,0 +1,415 @@ +""" +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 logging +import os +import stat +from pathlib import Path +from typing import TYPE_CHECKING, override + +from ruamel.yaml import YAML +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 + from typing import Final, Self + + from pydantic import ValidationError + + +__all__: "Sequence[str]" = ( + "SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME", + "SettingsDocument", + "get_settings_file_path", +) + + +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" + +SETTINGS_FILE_PATH_ENVIRONMENT_VARIABLE_NAME: "Final[str]" = "TEX_BOT_CONFIG_PATH" + + +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. + """ + + @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 + 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 + ) + + # 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 + 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 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() + _create_yaml_parser().dump(self._raw, output_buffer) + + 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. + + 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. + + 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. + + 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: + 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: + # 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) + + 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) + + 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) + + 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) diff --git a/config/_editor.py b/config/_editor.py new file mode 100644 index 000000000..db526345b --- /dev/null +++ b/config/_editor.py @@ -0,0 +1,319 @@ +""" +Changing individual settings within the deployment configuration file. + +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. 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 +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 exceptions import ( + InvalidSettingsFileError, + SettingsFileChangedError, + SettingsFileNotFoundError, + SettingsValidationError, + UnknownSettingError, +) + +from ._accessor import flatten_settings +from ._document import SettingsDocument +from ._schema import SettingsSchema, get_settings_metadata + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from pathlib import Path + from typing import Final + + from ._schema import ConfigSettingMetadata + + +__all__: "Sequence[str]" = ( + "SETTING_NAME_SEPARATOR", + "documented_setting_names", + "format_file_difference", + "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", "~"}) + + +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 _snapshot_of(document: SettingsDocument) -> SettingsSchema: + """Return the settings held by the given document, refusing it if they are invalid.""" + validation_error: ValidationError + try: + 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, + 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(file_path)) + ) + 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. + + 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( + 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. + 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 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 = _read_unchanged_file(loaded_document) + document.set_value(KEY_PATH, value) + + return _validated(document) + + +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 = _read_unchanged_file(loaded_document) + 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/_logging.py b/config/_logging.py new file mode 100644 index 000000000..4ffe58a93 --- /dev/null +++ b/config/_logging.py @@ -0,0 +1,118 @@ +""" +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") + + +# 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" # noqa: CAR201 + +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..b14622333 --- /dev/null +++ b/config/_messages.py @@ -0,0 +1,149 @@ +""" +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 pathlib import Path +from typing import TYPE_CHECKING, override + +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] + + # 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, list) + + return frozenset(str(single_message) for single_message in value) + + +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 + 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 new file mode 100644 index 000000000..6476324c7 --- /dev/null +++ b/config/_schema.py @@ -0,0 +1,843 @@ +""" +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, NamedTuple, get_args + +from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + HttpUrl, + SecretStr, # NOTE: Pydantic resolves field annotations at runtime # noqa: TC002 +) + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping, Sequence + from typing import Final + + from pydantic.fields import FieldInfo + + +__all__: "Sequence[str]" = ( + "AutoCookieCheckingSettings", + "CommandsSettings", + "CommunityGroupSettings", + "ConfigSettingMetadata", + "ConsoleLoggingSettings", + "DiscordAPILoggingSettings", + "DiscordChannelLoggingSettings", + "DiscordSettings", + "LinksSettings", + "LogLevel", + "LoggingSettings", + "MSLSettings", + "PingCommandSettings", + "ReminderSettings", + "RemindersSettings", + "SendIntroductionRemindersFlag", + "SendIntroductionRemindersSettings", + "SettingsSchema", + "StatsCommandSettings", + "StrikeCommandSettings", + "get_settings_metadata", + "nested_settings_model_of", + "setting_names_within", +) + + +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): + # 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() + + 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+)d)?" + r"(?:(?P(?:\d*\.)?\d+)h)?" + r"(?:(?P(?:\d*\.)?\d+)m)?" + 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: + """ + 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: A deviation from the previous StrictYAML implementation, which required time + resolutions in *ascending* order of size, so accepted `30m1h` but rejected `1h30m`. + + 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` + (or `DiscordTimeoutDuration`, which is additionally bounded above); + this parser itself accepts a zero-length duration written explicitly (`0s`). + """ + 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) + # 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 " + "'dhms' " + "(with time resolutions given in descending 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_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. 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 = ( + "Value should be a delay/interval string describing a duration longer than zero" + ) + raise ValueError(NON_POSITIVE_TIME_DELTA_MESSAGE) + + 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/"): + 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 PositiveTimeDelta = Annotated[ + datetime.timedelta, + 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)] +type NormalisedLogLevel = Annotated[LogLevel, BeforeValidator(_parse_log_level)] + + +class _BaseSettingsSchema(BaseModel): + """ + 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, + 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): # type: ignore[explicit-any] + """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): # type: ignore[explicit-any] + """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): # type: ignore[explicit-any] + """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): # type: ignore[explicit-any] + """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): # type: ignore[explicit-any] + """Settings describing how TeX-Bot connects to Discord.""" + + bot_token: Annotated[ + SecretStr, + 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([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): # type: ignore[explicit-any] + """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): # type: ignore[explicit-any] + """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": True, "secret": False}, + ) + 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}, + ) + + +class MSLSettings(_BaseSettingsSchema): # type: ignore[explicit-any] + """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" + "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}, + ) + auto_cookie_checking: AutoCookieCheckingSettings = Field( + default_factory=AutoCookieCheckingSettings + ) + + +class CommunityGroupSettings(_BaseSettingsSchema): # type: ignore[explicit-any] + """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." + ), + # 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, + 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." + ), + # 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 + # 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." + ), + json_schema_extra={"requires_restart": False, "secret": False}, + ) + links: LinksSettings + msl: MSLSettings + + +class PingCommandSettings(_BaseSettingsSchema): # type: ignore[explicit-any] + """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): # type: ignore[explicit-any] + """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}, + ) + + @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.""" + + 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: DiscordTimeoutDuration = Field( + default=datetime.timedelta(hours=24), + description=( + "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}, + ) + 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): # type: ignore[explicit-any] + """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): # type: ignore[explicit-any] + """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": False, "secret": False}, + ) + interval: PositiveTimeDelta = 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): # type: ignore[explicit-any] + """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": False, "secret": False}, + ) + interval: PositiveTimeDelta = 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): # type: ignore[explicit-any] + """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): # type: ignore[explicit-any] + """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}, + ) + + +class ConfigSettingMetadata(NamedTuple): + """The information describing a single configuration setting to a human.""" + + 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 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]]": + """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('_', '-')}" + + 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 + + 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, + holds_text=_holds_text(field.annotation), + ), + ) + + +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. + + 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/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 dc3c44ced..3b506cd16 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 ( + ImproperlyConfiguredError, + RestartRequiredDueToConfigChange, +) from .does_not_exist import ( ApplicantRoleDoesNotExistError, ArchivistRoleDoesNotExistError, @@ -25,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: @@ -45,6 +56,7 @@ "InvalidActionDescriptionError", "InvalidActionTargetError", "InvalidMessagesJSONFileError", + "InvalidSettingsFileError", "MSLMembershipError", "MemberRoleDoesNotExistError", "MessagesJSONFileMissingKeyError", @@ -54,5 +66,10 @@ "RoleDoesNotExistError", "RolesChannelDoesNotExistError", "RulesChannelDoesNotExistError", + "SettingsFileChangedError", + "SettingsFileNotFoundError", + "SettingsNotLoadedError", + "SettingsValidationError", "StrikeTrackingError", + "UnknownSettingError", ) diff --git a/exceptions/config_changes.py b/exceptions/config_changes.py index 9ea28c41a..1515ab532 100644 --- a/exceptions/config_changes.py +++ b/exceptions/config_changes.py @@ -10,16 +10,19 @@ from collections.abc import Sequence from collections.abc import Set as AbstractSet -__all__: "Sequence[str]" = ("ImproperlyConfiguredError", "RestartRequiredDueToConfigChange") +__all__: "Sequence[str]" = ( + "ImproperlyConfiguredError", + "RestartRequiredDueToConfigChange", +) class ImproperlyConfiguredError(BaseTeXBotError, Exception): - """Exception class to raise when environment variables are not correctly provided.""" + """Exception class to raise when a configuration value is not correctly provided.""" @classproperty @override def DEFAULT_MESSAGE(cls) -> str: - return "One or more provided environment variable values are invalid." + return "One or more provided configuration values are invalid." class RestartRequiredDueToConfigChange(BaseTeXBotError, Exception): # noqa: N818 @@ -35,6 +38,6 @@ def __init__( self, message: str | None = None, changed_settings: "AbstractSet[str] | None" = None ) -> None: """Initialise an Exception to apply configuration changes.""" - self.changed_settings: AbstractSet[str] | None = changed_settings or set() + self.changed_settings: AbstractSet[str] = changed_settings or set() super().__init__(message) 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/main.py b/main.py index 548f1e3cd..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. """ @@ -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 b0683919e..cd90a16ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,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", "typed_classproperties>=1.2", "validators>=0.34" ] @@ -75,7 +77,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 @@ -106,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/stubs/discord/commands/core.pyi b/stubs/discord/commands/core.pyi index e194a0b73..930ea72cb 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,13 @@ __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 +42,7 @@ def command[**P]( class ApplicationCommand: qualified_name: str + callback: CommandCallback class SlashCommand(ApplicationCommand): ... class UserCommand(ApplicationCommand): ... @@ -43,6 +51,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/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/__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..286424ade --- /dev/null +++ b/tests/config/conftest.py @@ -0,0 +1,102 @@ +"""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, Iterator, Sequence + from logging import Handler, Logger + 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(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.""" + + 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..3144e60a0 --- /dev/null +++ b/tests/config/test_accessor.py @@ -0,0 +1,405 @@ +"""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_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 + @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.""" + 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 + + @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 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( + 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: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 + 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.""" + + @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_command.py b/tests/config/test_command.py new file mode 100644 index 000000000..77762103c --- /dev/null +++ b/tests/config/test_command.py @@ -0,0 +1,286 @@ +"""Test suite for the "/config" command group.""" + +import asyncio +from typing import TYPE_CHECKING, cast, override + +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.""" + + @override + 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_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", + ) -> 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 new file mode 100644 index 000000000..e39924ddf --- /dev/null +++ b/tests/config/test_document.py @@ -0,0 +1,611 @@ +"""Test suite for reading, writing & error-reporting of the configuration file.""" + +import os +import stat +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 ( + PROJECT_ROOT, + 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_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.""" + 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: # noqa: CAR150 + """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" + ) -> 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.object( + SettingsDocument, + "_write_privately", + 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 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_document_read_again_is_independent_of_the_one_already_held( + config_file: "Path", + ) -> None: + """ + Test that changing a freshly read document leaves an existing one alone. + + 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. + """ + loaded_document: SettingsDocument = SettingsDocument.load(config_file) + + document_being_changed: SettingsDocument = SettingsDocument.load(config_file) + document_being_changed.set_value(["community-group", "full-name"], "CompSoc") + + assert not loaded_document.contains(["community-group", "full-name"]) + assert document_being_changed.contains(["community-group", "full-name"]) + + +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 + + @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.""" + + @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() + + +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. + + 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_editor.py b/tests/config/test_editor.py new file mode 100644 index 000000000..593168883 --- /dev/null +++ b/tests/config/test_editor.py @@ -0,0 +1,834 @@ +"""Test suite for changing individual settings within the configuration file.""" + +import datetime +from typing import TYPE_CHECKING + +import pytest + +import config +from config import ( + SettingsFileChangedError, + 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, + validated_document_with_setting_set, +) + +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, 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) + + +class TestChangesMadeByHand: + """ + Test case for a change made from within Discord meeting one made by hand. + + 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: + """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 + @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 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( + CONFIG_FILE_PATH, + "full-name: Computer Science Society", + "full-name: Edited By Hand", + ) + + with pytest.raises(SettingsFileChangedError): + config.unset_setting("commands:ping:easter-egg-probability") + + assert config.settings.community_group.full_name == "Computer Science Society" + + @staticmethod + def test_reloading_first_allows_the_change_to_be_made( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that loading the edits made by hand is enough to allow a change again. + + 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, + "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 + ) + + @staticmethod + def test_a_comment_added_by_hand_is_cleared_by_reloading( + configured: "Callable[[str], Path]", + ) -> None: + """ + 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, "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 "# A comment added by hand." in CONFIG_FILE_PATH.read_text(encoding="utf-8") + + @staticmethod + def test_a_change_made_from_discord_does_not_refuse_the_next_one( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that changing a setting does not leave the file looking edited by hand. + + 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 == "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]", + ) -> None: + """Test that a file nobody has touched is not mistaken for one that was edited.""" + configured(COMMENTED_CONFIG) + + assert not config.settings.file_has_changed() + + @staticmethod + def test_differing_line_endings_are_not_mistaken_for_an_edit( + configured: "Callable[[str], Path]", + ) -> None: + """ + Test that rewriting the file with other line endings does not count as an edit. + + 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) + + CONFIG_FILE_PATH.write_bytes( + COMMENTED_CONFIG.replace("\n", "\r\n").encode(encoding="utf-8") + ) + + assert not config.settings.file_has_changed() + + +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 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.""" + + @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/tests/config/test_logging.py b/tests/config/test_logging.py new file mode 100644 index 000000000..5ed6dd9f3 --- /dev/null +++ b/tests/config/test_logging.py @@ -0,0 +1,230 @@ +"""Test suite for applying the logging configuration.""" + +import logging +from typing import TYPE_CHECKING + +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 Mapping, Sequence + from logging import Handler + 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": # noqa: CAR150 + """Build the logging section of a validated configuration.""" + return SettingsSchema.model_validate( + {**REQUIRED_SETTINGS, "logging": logging_overrides} + ).logging + + +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_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: + """ + 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_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.""" + 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..acc04cd76 --- /dev/null +++ b/tests/config/test_messages.py @@ -0,0 +1,212 @@ +"""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() + + @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.""" + + @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..2b02731ce --- /dev/null +++ b/tests/config/test_schema.py @@ -0,0 +1,717 @@ +"""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]": # noqa: CAR150 + """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: An empty string is not a duration + "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}}) + ) + + @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) + + @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.""" + + @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 + @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 hyphens or underscores anywhere within it is accepted. + + 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. + """ + settings: SettingsSchema = SettingsSchema.model_validate( + { + **REQUIRED_SETTINGS, + "discord": {"bot-token": bot_token, "main-guild-id": VALID_MAIN_GUILD_ID}, + } + ) + + assert settings.discord.bot_token.get_secret_value() == bot_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.""" + 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_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.""" + 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"), + ("on", "once"), + ("1", "once"), + (" Once ", "once"), + (False, False), + ("false", False), + ("no", False), + ("off", False), + ("0", 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 + @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.""" + 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: 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", + "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: 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: + """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"], 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. + + `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}}) + ) + + @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}}) + ) diff --git a/tex-bot-deployment.example.yaml b/tex-bot-deployment.example.yaml new file mode 100644 index 000000000..c0fcf3295 --- /dev/null +++ b/tex-bot-deployment.example.yaml @@ -0,0 +1,120 @@ +# 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. +# 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; 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. +# +# 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. +# +# 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: +# +# * `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: + # + 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: [] + + # 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 + +# 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 + # 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 + +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/__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 e56b7e072..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,34 +38,95 @@ "Expires": "0", } -BASE_SU_PLATFORM_WEB_COOKIES: "Mapping[str, str]" = { - ".AspNet.SharedCookie": settings["SU_PLATFORM_ACCESS_COOKIE"], -} +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['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() @@ -73,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() diff --git a/utils/tex_bot.py b/utils/tex_bot.py index 714eecf37..5961a8e4a 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] @@ -287,10 +286,11 @@ 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["_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 +309,7 @@ def group_short_name(self) -> str: """ return ( ( - settings["_GROUP_SHORT_NAME"] + settings.community_group.short_name or ( "CSS" if ( @@ -494,20 +494,20 @@ 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 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." + "when no logging:discord-channel:webhook-url has been set." ) raise ValueError(NO_LOG_CHANNEL_MESSAGE) 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 e720ba094..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]] @@ -63,6 +63,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 = "application-file-scanner" version = "0.6.4" @@ -102,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]] @@ -198,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]] @@ -284,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]] @@ -329,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" }, @@ -351,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] @@ -363,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]] @@ -498,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]] @@ -662,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]] @@ -718,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]] @@ -807,6 +837,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" @@ -985,29 +1056,38 @@ 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" -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]] @@ -1030,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]] @@ -1077,8 +1157,10 @@ main = [ { name = "mplcyberpunk" }, { name = "parsedatetime" }, { name = "py-cord" }, + { name = "pydantic" }, { name = "python-dotenv" }, { name = "python-logging-discord-handler" }, + { name = "ruamel-yaml" }, { name = "typed-classproperties" }, { name = "validators" }, ] @@ -1123,8 +1205,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 = "typed-classproperties", specifier = ">=1.2" }, { name = "validators", specifier = ">=0.34" }, ] @@ -1201,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]] @@ -1226,6 +1310,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" @@ -1264,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" }, ]