diff --git a/.github/workflows/code-format.yml b/.github/workflows/code-format.yml index 2c8cf73..8eb55aa 100644 --- a/.github/workflows/code-format.yml +++ b/.github/workflows/code-format.yml @@ -9,12 +9,12 @@ jobs: - uses: actions/checkout@v3 - uses: chartboost/ruff-action@v1 with: - version: 0.1.15 + version: 0.16.3 ruff-format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: chartboost/ruff-action@v1 with: - version: 0.1.15 + version: 0.16.3 args: format diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index adfff56..bf675a9 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,20 +15,17 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11"] + python-version: ["3.12", "3.13"] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Run image - uses: abatilo/actions-poetry@v2.0.0 + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - poetry-version: '1.2.0' + enable-cache: true + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} - name: Install Dependencies - run: poetry install + run: uv sync --all-extras --dev - name: Run Tests - run: | - poetry run pytest + run: uv run pytest diff --git a/.gitignore b/.gitignore index 59a7136..a34c3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,3 @@ venv.bak/ # PyCharm .idea .vscode/ - -# Poetry -poetry.lock \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1414718..50bb8fd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,13 +2,13 @@ fail_fast: true repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.15 + rev: v0.16.3 hooks: - - id: ruff + - id: ruff-check args: [ --fix ] - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v6.0.0 hooks: - id: check-added-large-files args: ['--maxkb=1024'] diff --git a/CHANGES.md b/CHANGES.md index e8aeef7..4dd0ccd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## development +### 🎉 Features +- add support for message components (action rows, buttons, select menus, text inputs) +- add support for polls + ## 2025-03-04 1.4.1 ### 🩹 Fixes diff --git a/README.md b/README.md index 42b77c1..0c031d9 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ webhook = DiscordWebhook(url="your webhook url", content="Webhook Message") response = webhook.execute() ``` +![Image](img/basic_webhook.png "Basic Example Result") + ### Create multiple instances If you want to use multiple URLs you need to create multiple instances. @@ -89,8 +91,6 @@ webhook = DiscordWebhook(url="your webhook url", rate_limit_retry=True, content= response = webhook.execute() ``` -![Image](img/basic_webhook.png "Basic Example Result") - ### Webhook with Embedded Content ```python @@ -293,6 +293,139 @@ webhook.remove_file("example.jpg") response = webhook.execute() ``` +### Send a Poll + +A poll can contain up to 10 answers and is open for 24 hours by default. +The `duration` is set in hours and can be up to 768 (32 days). + +```python +from discord_webhook import ( + DiscordComponentEmoji, + DiscordPoll, + DiscordPollAnswer, + DiscordWebhook, +) + +# a simple poll with two answers +poll = DiscordPoll(question="Your favourite color?", answers=["Red", "Blue"]) + +webhook = DiscordWebhook(url="your webhook url", poll=poll) +response = webhook.execute() +``` + +Answers can also use emojis and be added afterwards: + +```python +poll = DiscordPoll( + question="Which languages do you use?", + answers=[ + DiscordPollAnswer(text="Python", emoji=DiscordComponentEmoji(name="🐍")), + DiscordPollAnswer(text="Rust", emoji=DiscordComponentEmoji(name="🦀")), + ], + allow_multiselect=True, + duration=72, +) +poll.add_answer("Something else") + +webhook = DiscordWebhook(url="your webhook url") +webhook.set_poll(poll) +response = webhook.execute() +``` + +Use `webhook.remove_poll()` to remove an already set poll and +`poll.get_answers()` / `poll.remove_answer(index)` to manage the answers. + +### Add Components + +Components like buttons and select menus are always wrapped in an action row. +A message can contain up to 5 action rows, an action row can contain up to 5 buttons +*or* a single select menu. + +Note that Discord only renders interactive components for webhooks that are owned by an +application. Link buttons work with every webhook. + +```python +from discord_webhook import ( + DiscordComponentActionRow, + DiscordComponentButton, + DiscordComponentEmoji, + DiscordComponentStringSelect, + DiscordSelectOption, + DiscordWebhook, + constants, +) + +webhook = DiscordWebhook(url="your webhook url", content="Webhook Message") + +# a row with two buttons +buttons = DiscordComponentActionRow( + components=[ + DiscordComponentButton( + style=constants.DISCORD_COMPONENT_BUTTON_STYLE_LINK, + label="Documentation", + url="https://github.com/lovvskillz/python-discord-webhook", + ), + DiscordComponentButton( + style=constants.DISCORD_COMPONENT_BUTTON_STYLE_DANGER, + custom_id="delete_message", + label="Delete", + emoji=DiscordComponentEmoji(name="🗑️"), + ), + ] +) +webhook.add_component_row(buttons) + +# a select menu needs its own action row +select = DiscordComponentStringSelect( + custom_id="favourite_color", + placeholder="Choose your favourite color", + options=[ + DiscordSelectOption(label="Red", value="red", description="like a tomato"), + DiscordSelectOption(label="Blue", value="blue", default=True), + ], +) +webhook.add_component_row(DiscordComponentActionRow(components=[select])) + +response = webhook.execute() +``` + +These components are available: + +| Class | Description | +|--------------------------------------|------------------------------------------------------| +| `DiscordComponentActionRow` | container for other components | +| `DiscordComponentButton` | button (styles 1 - 5) | +| `DiscordComponentStringSelect` | select menu with your own options | +| `DiscordComponentUserSelect` | select menu with users | +| `DiscordComponentRoleSelect` | select menu with roles | +| `DiscordComponentMentionableSelect` | select menu with users and roles | +| `DiscordComponentChannelSelect` | select menu with channels | +| `DiscordComponentTextInput` | text input (modals only) | +| `DiscordSelectOption` | option of a `DiscordComponentStringSelect` | +| `DiscordComponentEmoji` | emoji for buttons and select options | + +The select menus that are populated by Discord accept default values: + +```python +from discord_webhook import DiscordComponentChannelSelect, constants + +channel_select = DiscordComponentChannelSelect( + custom_id="channel", + placeholder="Choose a channel", + channel_types=[constants.DISCORD_CHANNEL_TYPE_GUILD_TEXT], + max_values=3, +) +channel_select.add_default_value(123456789) +``` + +Already added action rows can be inspected and removed again: + +```python +webhook.get_component_rows() +webhook.remove_component_row(0) +webhook.remove_component_rows() +``` + ### Allowed Mentions Look into the [Discord Docs](https://discord.com/developers/docs/resources/channel#allowed-mentions-object) for examples and for more explanation. @@ -430,20 +563,20 @@ optional arguments: ## Development ### Dev Setup -This project uses [Poetry](https://python-poetry.org/docs/) for dependency management and packaging. +This project uses [uv](https://docs.astral.sh/uv/) for dependency management and packaging. -Install Poetry and add Poetry to [Path](https://python-poetry.org/docs/#installation). +Install uv: **Debian / Ubuntu / Mac** -`curl -sSL https://install.python-poetry.org | python3 -` +`curl -LsSf https://astral.sh/uv/install.sh | sh` **Windows** -open powershell and run: `(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py -` +open powershell and run: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"` -Install dependencies: `poetry install` +Install dependencies: `uv sync --all-extras --dev` -Install the defined pre-commit hooks: `poetry run pre-commit install` +Install the defined pre-commit hooks: `uv run pre-commit install` -Activate the virtualenv: `poetry shell` \ No newline at end of file +Run a command in the virtualenv: `uv run pytest` \ No newline at end of file diff --git a/discord_webhook/__init__.py b/discord_webhook/__init__.py index b6a966f..9d37011 100644 --- a/discord_webhook/__init__.py +++ b/discord_webhook/__init__.py @@ -1,5 +1,35 @@ -__all__ = ["DiscordWebhook", "DiscordEmbed", "AsyncDiscordWebhook"] +__all__ = [ + "AsyncDiscordWebhook", + "DiscordComponentActionRow", + "DiscordComponentButton", + "DiscordComponentChannelSelect", + "DiscordComponentEmoji", + "DiscordComponentMentionableSelect", + "DiscordComponentRoleSelect", + "DiscordComponentStringSelect", + "DiscordComponentTextInput", + "DiscordComponentUserSelect", + "DiscordEmbed", + "DiscordPoll", + "DiscordPollAnswer", + "DiscordPollMedia", + "DiscordSelectOption", + "DiscordWebhook", +] -from .webhook import DiscordWebhook, DiscordEmbed -from .async_webhook import AsyncDiscordWebhook +from .components import ( # isort:skip + DiscordComponentActionRow, + DiscordComponentButton, + DiscordComponentChannelSelect, + DiscordComponentEmoji, + DiscordComponentMentionableSelect, + DiscordComponentRoleSelect, + DiscordComponentStringSelect, + DiscordComponentTextInput, + DiscordComponentUserSelect, + DiscordSelectOption, +) +from .poll import DiscordPoll, DiscordPollAnswer, DiscordPollMedia # isort:skip +from .webhook import DiscordEmbed, DiscordWebhook # isort:skip +from .async_webhook import AsyncDiscordWebhook # isort:skip diff --git a/discord_webhook/__main__.py b/discord_webhook/__main__.py index 1eb8c89..04db1ce 100644 --- a/discord_webhook/__main__.py +++ b/discord_webhook/__main__.py @@ -1,4 +1,5 @@ -""" Entry point to trigger webhook(s). """ +"""Entry point to trigger webhook(s).""" + import argparse import sys diff --git a/discord_webhook/async_webhook.py b/discord_webhook/async_webhook.py index 250c658..0e782a7 100644 --- a/discord_webhook/async_webhook.py +++ b/discord_webhook/async_webhook.py @@ -86,9 +86,7 @@ async def handle_rate_limit(self, response, request) -> "httpx.Response": raise HTTPException(errors) wh_sleep = float(errors["retry_after"]) + 0.15 logger.error( - "Webhook rate limited: sleeping for {wh_sleep} seconds...".format( - wh_sleep=round(wh_sleep, 2) - ) + f"Webhook rate limited: sleeping for {round(wh_sleep, 2)} seconds..." ) await asyncio.sleep(wh_sleep) response = await request() @@ -125,12 +123,12 @@ async def edit(self) -> "httpx.Response": Edit an already sent webhook with updated data. :return: Response of the sent webhook """ - assert isinstance( - self.id, str - ), "Webhook ID needs to be set in order to edit the webhook." - assert isinstance( - self.url, str - ), "Webhook URL needs to be set in order to edit the webhook." + assert isinstance(self.id, str), ( + "Webhook ID needs to be set in order to edit the webhook." + ) + assert isinstance(self.url, str), ( + "Webhook URL needs to be set in order to edit the webhook." + ) async with self.http_client as client: # type: httpx.AsyncClient url = f"{self.url}/messages/{self.id}" if bool(self.files) is False: @@ -149,7 +147,7 @@ async def edit(self) -> "httpx.Response": request = partial(client.patch, url, **patch_kwargs) response = await request() if response.status_code in [200, 204]: - logger.debug("Webhook with id {id} edited".format(id=self.id)) + logger.debug(f"Webhook with id {self.id} edited") elif response.status_code == 429 and self.rate_limit_retry: response = await self.handle_rate_limit(response, request) logger.debug("Webhook edited") @@ -167,12 +165,12 @@ async def delete(self) -> "httpx.Response": Delete the already sent webhook. :return: webhook response """ - assert isinstance( - self.id, str - ), "Webhook ID needs to be set in order to delete the webhook." - assert isinstance( - self.url, str - ), "Webhook URL needs to be set in order to delete the webhook." + assert isinstance(self.id, str), ( + "Webhook ID needs to be set in order to delete the webhook." + ) + assert isinstance(self.url, str), ( + "Webhook URL needs to be set in order to delete the webhook." + ) url = f"{self.url}/messages/{self.id}" async with self.http_client as client: # type: httpx.AsyncClient response = await client.delete( diff --git a/discord_webhook/components.py b/discord_webhook/components.py new file mode 100644 index 0000000..8a69763 --- /dev/null +++ b/discord_webhook/components.py @@ -0,0 +1,485 @@ +from typing import Any + +from . import constants +from .utils import check_max_length, is_int, to_dict +from .webhook_exceptions import ComponentException + + +class DiscordComponentEmoji: + """ + Represent a partial emoji that can be used in components. + """ + + animated: bool | None + id: str | None + name: str | None + + def __init__( + self, + name: str | None = None, + id: str | None = None, + animated: bool = False, + ) -> None: + """ + :param str name: name of the emoji (unicode emoji for standard emojis) + :param str id: id of the emoji for custom emojis + :param bool animated: whether the emoji is animated + """ + if not name and not id: + raise ComponentException("Either name or id needs to be provided.") + + self.name = name + self.id = id + self.animated = animated + + def to_dict(self) -> dict[str, Any]: + """ + Convert the emoji to a dict. + :return: emoji as dict + """ + return {key: value for key, value in self.__dict__.items() if value is not None} + + +class BaseDiscordComponent: + """ + A base class for discord components. + """ + + custom_id: str + label: str + type: int + + def __init__(self, **kwargs): + self.custom_id = kwargs.get("custom_id") + self.label = kwargs.get("label") + + if not is_int(self.type) or self.type not in constants.DISCORD_COMPONENT_TYPES: + raise ComponentException( + "The provided component type is invalid. A valid component type is an" + " integer between 1 and 8." + ) + if self.custom_id and len(self.custom_id) > 100: + raise ComponentException("custom_id can be a maximum of 100 characters.") + + def to_dict(self) -> dict[str, Any]: + """ + Convert the component to a dict. + :return: component as dict + """ + data = {"type": self.type} + for key, value in self.__dict__.items(): + if value is None or value == []: + continue + data[key] = to_dict(value) + return data + + +class DiscordComponentButton(BaseDiscordComponent): + """ + Represent a button that can be used in a message. + """ + + disabled: bool | None + emoji: dict | DiscordComponentEmoji | None + label: str | None + style: int + type: int + url: str | None + + def __init__( + self, style: int = constants.DISCORD_COMPONENT_BUTTON_STYLE_PRIMARY, **kwargs + ): + """ + :param style: button style (int 1 - 5) + :keyword disabled: Whether the button is disabled (defaults to false) + :keyword custom_id: developer-defined identifier for the button + :keyword emoji: emoji that appears on the button + :keyword label: Text that appears on the button + :keyword url: URL for DISCORD_COMPONENT_BUTTON_STYLE_LINK (int 5) buttons + """ + self.type = constants.DISCORD_COMPONENT_TYPE_BUTTON + self.style = style + self.disabled = kwargs.get("disabled", False) + self.custom_id = kwargs.get("custom_id") + self.emoji = kwargs.get("emoji") + self.label = kwargs.get("label") + self.url = kwargs.get("url") + + if ( + not is_int(self.style) + or self.style not in constants.DISCORD_COMPONENT_BUTTON_STYLES + ): + raise ComponentException( + "The provided button style is invalid. A valid button style is an" + " integer between 1 and 5." + ) + if ( + constants.DISCORD_COMPONENT_BUTTON_STYLE_PRIMARY + <= self.style + <= constants.DISCORD_COMPONENT_BUTTON_STYLE_DANGER + and not self.custom_id + ): + raise ComponentException("custom_id needs to be provided as a kwarg.") + if self.style == constants.DISCORD_COMPONENT_BUTTON_STYLE_LINK and not self.url: + raise ComponentException("url needs to be provided as a kwarg.") + if self.label and len(self.label) > 80: + raise ComponentException( + "The label can be a maximum of 80 characters long." + ) + + super().__init__(**kwargs) + + +class DiscordSelectOption: + """ + Represent an option of a string select menu. + """ + + default: bool | None + description: str | None + emoji: dict | DiscordComponentEmoji | None + label: str + value: str + + def __init__(self, label: str, value: str, **kwargs) -> None: + """ + :param str label: user-facing name of the option + :param str value: developer-defined value of the option + :keyword str description: additional description of the option + :keyword emoji: emoji that appears next to the option + :keyword bool default: whether the option is selected by default + """ + self.label = label + self.value = value + self.description = kwargs.get("description") + self.emoji = kwargs.get("emoji") + self.default = kwargs.get("default", False) + + check_max_length(self.label, 100, "label", ComponentException) + check_max_length(self.value, 100, "value", ComponentException) + check_max_length(self.description, 100, "description", ComponentException) + + def to_dict(self) -> dict[str, Any]: + """ + Convert the option to a dict. + :return: option as dict + """ + return { + key: to_dict(value) + for key, value in self.__dict__.items() + if value is not None + } + + +class BaseDiscordComponentSelect(BaseDiscordComponent): + """ + A base class for the different select menus. + """ + + disabled: bool | None + max_values: int | None + min_values: int | None + placeholder: str | None + + def __init__(self, custom_id: str, **kwargs) -> None: + """ + :param str custom_id: developer-defined identifier for the select menu + :keyword bool disabled: whether the select menu is disabled (defaults to false) + :keyword int max_values: maximum amount of options that can be chosen (1 - 25) + :keyword int min_values: minimum amount of options that have to be chosen (0 - 25) + :keyword str placeholder: text that is shown if nothing is selected + """ + kwargs["custom_id"] = custom_id + self.disabled = kwargs.get("disabled", False) + self.max_values = kwargs.get("max_values", 1) + self.min_values = kwargs.get("min_values", 1) + self.placeholder = kwargs.get("placeholder") + + if not custom_id: + raise ComponentException("custom_id needs to be provided.") + check_max_length(self.placeholder, 150, "placeholder", ComponentException) + for name, value, minimum in [ + ("min_values", self.min_values, 0), + ("max_values", self.max_values, 1), + ]: + if not is_int(value) or not minimum <= value <= 25: + raise ComponentException( + f"{name} needs to be an integer between {minimum} and 25." + ) + + super().__init__(**kwargs) + + +class DiscordComponentStringSelect(BaseDiscordComponentSelect): + """ + Represent a select menu with developer-defined options. + """ + + options: list[dict | DiscordSelectOption] + + def __init__( + self, + custom_id: str, + options: list[dict | DiscordSelectOption] | None = None, + **kwargs, + ) -> None: + """ + :param str custom_id: developer-defined identifier for the select menu + :param list options: choices of the select menu (up to 25) + """ + self.type = constants.DISCORD_COMPONENT_TYPE_STRING_SELECT + self.options = [] + + super().__init__(custom_id, **kwargs) + + for option in options or []: + self.add_option(option) + + def add_option(self, option: dict | DiscordSelectOption) -> None: + """ + Add an option to the select menu. + :param option: option instance or dict + """ + if len(self.options) >= constants.DISCORD_MAX_SELECT_OPTIONS: + raise ComponentException( + "A select menu can contain up to" + f" {constants.DISCORD_MAX_SELECT_OPTIONS} options." + ) + self.options.append(option) + + +class BaseDiscordComponentAutoPopulatedSelect(BaseDiscordComponentSelect): + """ + A base class for select menus that are populated by Discord itself. + """ + + default_values: list[dict[str, str]] + + #: value of the ``type`` field of a default value entry + default_value_type: str = "" + + def __init__(self, custom_id: str, **kwargs) -> None: + """ + :param str custom_id: developer-defined identifier for the select menu + :keyword list default_values: ids that are selected by default + """ + self.default_values = [] + + super().__init__(custom_id, **kwargs) + + for default_value in kwargs.get("default_values") or []: + self.add_default_value(default_value) + + def add_default_value( + self, value: str | int | dict[str, str], value_type: str | None = None + ) -> None: + """ + Add a default value to the select menu. + :param value: id of the user, role or channel or a dict with id and type + :param str value_type: type of the value ("user", "role" or "channel") + """ + if not isinstance(value, dict): + value = { + "id": str(value), + "type": value_type or self.default_value_type, + } + if value.get("type") not in ["user", "role", "channel"]: + raise ComponentException( + 'The type of a default value needs to be "user", "role" or "channel".' + ) + self.default_values.append(value) + + +class DiscordComponentUserSelect(BaseDiscordComponentAutoPopulatedSelect): + """ + Represent a select menu for users. + """ + + default_value_type = "user" + + def __init__(self, custom_id: str, **kwargs) -> None: + self.type = constants.DISCORD_COMPONENT_TYPE_USER_SELECT + super().__init__(custom_id, **kwargs) + + +class DiscordComponentRoleSelect(BaseDiscordComponentAutoPopulatedSelect): + """ + Represent a select menu for roles. + """ + + default_value_type = "role" + + def __init__(self, custom_id: str, **kwargs) -> None: + self.type = constants.DISCORD_COMPONENT_TYPE_ROLE_SELECT + super().__init__(custom_id, **kwargs) + + +class DiscordComponentMentionableSelect(BaseDiscordComponentAutoPopulatedSelect): + """ + Represent a select menu for mentionables (users and roles). + """ + + default_value_type = "user" + + def __init__(self, custom_id: str, **kwargs) -> None: + self.type = constants.DISCORD_COMPONENT_TYPE_MENTIONABLE_SELECT + super().__init__(custom_id, **kwargs) + + +class DiscordComponentChannelSelect(BaseDiscordComponentAutoPopulatedSelect): + """ + Represent a select menu for channels. + """ + + channel_types: list[int] + default_value_type = "channel" + + def __init__( + self, custom_id: str, channel_types: list[int] | None = None, **kwargs + ) -> None: + """ + :param str custom_id: developer-defined identifier for the select menu + :param list channel_types: channel types that should be included in the menu + """ + self.type = constants.DISCORD_COMPONENT_TYPE_CHANNEL_SELECT + self.channel_types = channel_types or [] + + for channel_type in self.channel_types: + if channel_type not in constants.DISCORD_CHANNEL_TYPES: + raise ComponentException( + f"{channel_type!r} is not a valid channel type." + ) + + super().__init__(custom_id, **kwargs) + + +class DiscordComponentTextInput(BaseDiscordComponent): + """ + Represent a text input that can be used in a modal. + """ + + max_length: int | None + min_length: int | None + placeholder: str | None + required: bool | None + style: int + value: str | None + + def __init__( + self, + custom_id: str, + label: str, + style: int = constants.DISCORD_COMPONENT_TEXT_INPUT_STYLE_SHORT, + **kwargs, + ) -> None: + """ + :param str custom_id: developer-defined identifier for the text input + :param str label: label of the text input + :param int style: text input style (int 1 - 2) + :keyword int max_length: maximum input length (1 - 4000) + :keyword int min_length: minimum input length (0 - 4000) + :keyword str placeholder: text that is shown if the input is empty + :keyword bool required: whether the text input is required (defaults to true) + :keyword str value: pre-filled value of the text input + """ + kwargs["custom_id"] = custom_id + kwargs["label"] = label + self.type = constants.DISCORD_COMPONENT_TYPE_TEXT_INPUT + self.style = style + self.max_length = kwargs.get("max_length") + self.min_length = kwargs.get("min_length") + self.placeholder = kwargs.get("placeholder") + self.required = kwargs.get("required", True) + self.value = kwargs.get("value") + + if ( + not is_int(self.style) + or self.style not in constants.DISCORD_COMPONENT_TEXT_INPUT_STYLES + ): + raise ComponentException( + "The provided text input style is invalid. A valid text input style is" + " an integer between 1 and 2." + ) + if not custom_id: + raise ComponentException("custom_id needs to be provided.") + check_max_length(label, 45, "label", ComponentException) + check_max_length(self.placeholder, 100, "placeholder", ComponentException) + check_max_length(self.value, 4000, "value", ComponentException) + + super().__init__(**kwargs) + + +class DiscordComponentActionRow(BaseDiscordComponent): + """ + Represent an action row that can be used in a message. + """ + + components: list + type: int = constants.DISCORD_COMPONENT_TYPE_ACTION_ROW + + def __init__( + self, + components: list[dict | BaseDiscordComponent] | None = None, + **kwargs, + ): + """ + :keyword components: displayed components in an action row + """ + self.components = [] + self.type = constants.DISCORD_COMPONENT_TYPE_ACTION_ROW + + super().__init__(**kwargs) + + for component in components or []: + self.add_component(component) + + def _component_types(self) -> list[int | None]: + """ + Get the types of all already added components. + :return: list of component types + """ + return [component.get("type") for component in self.components] + + def add_component(self, component: dict | BaseDiscordComponent) -> None: + """ + Add a component to the row + :param component: discord component instance + """ + if isinstance(component, DiscordComponentActionRow): + raise ComponentException("An action row can't contain another action row.") + if ( + isinstance(component, DiscordComponentButton) + and sum( + 1 + for component_type in self._component_types() + if component_type == constants.DISCORD_COMPONENT_TYPE_BUTTON + ) + >= constants.DISCORD_MAX_BUTTONS_PER_ACTION_ROW + ): + raise ComponentException("An Action Row can contain up to 5 buttons.") + + if not isinstance(component, dict): + component = component.to_dict() + + component_type = component.get("type") + existing_types = self._component_types() + exclusive_types = constants.DISCORD_COMPONENT_SELECT_TYPES + [ + constants.DISCORD_COMPONENT_TYPE_TEXT_INPUT + ] + if existing_types and ( + component_type in exclusive_types + or any(existing_type in exclusive_types for existing_type in existing_types) + ): + raise ComponentException( + "A select menu or text input has to be the only component in an action" + " row." + ) + + self.components.append(component) + + def to_dict(self) -> dict[str, Any]: + """ + Convert the action row and its components to a dict. + :return: action row as dict + """ + return {"type": self.type, "components": to_dict(self.components)} diff --git a/discord_webhook/constants.py b/discord_webhook/constants.py index 65bf80d..9dd74bc 100644 --- a/discord_webhook/constants.py +++ b/discord_webhook/constants.py @@ -5,3 +5,108 @@ class MessageFlags(Enum): NONE = 0 SUPPRESS_EMBEDS = 4 SUPPRESS_NOTIFICATIONS = 4096 + + +DISCORD_COMPONENT_TYPE_ACTION_ROW = 1 +DISCORD_COMPONENT_TYPE_BUTTON = 2 +DISCORD_COMPONENT_TYPE_STRING_SELECT = 3 +DISCORD_COMPONENT_TYPE_TEXT_INPUT = 4 +DISCORD_COMPONENT_TYPE_USER_SELECT = 5 +DISCORD_COMPONENT_TYPE_ROLE_SELECT = 6 +DISCORD_COMPONENT_TYPE_MENTIONABLE_SELECT = 7 +DISCORD_COMPONENT_TYPE_CHANNEL_SELECT = 8 + +DISCORD_COMPONENT_TYPES = [ + DISCORD_COMPONENT_TYPE_ACTION_ROW, + DISCORD_COMPONENT_TYPE_BUTTON, + DISCORD_COMPONENT_TYPE_STRING_SELECT, + DISCORD_COMPONENT_TYPE_TEXT_INPUT, + DISCORD_COMPONENT_TYPE_USER_SELECT, + DISCORD_COMPONENT_TYPE_ROLE_SELECT, + DISCORD_COMPONENT_TYPE_MENTIONABLE_SELECT, + DISCORD_COMPONENT_TYPE_CHANNEL_SELECT, +] + +DISCORD_COMPONENT_SELECT_TYPES = [ + DISCORD_COMPONENT_TYPE_STRING_SELECT, + DISCORD_COMPONENT_TYPE_USER_SELECT, + DISCORD_COMPONENT_TYPE_ROLE_SELECT, + DISCORD_COMPONENT_TYPE_MENTIONABLE_SELECT, + DISCORD_COMPONENT_TYPE_CHANNEL_SELECT, +] + +DISCORD_COMPONENT_BUTTON_STYLE_PRIMARY = 1 +DISCORD_COMPONENT_BUTTON_STYLE_SECONDARY = 2 +DISCORD_COMPONENT_BUTTON_STYLE_SUCCESS = 3 +DISCORD_COMPONENT_BUTTON_STYLE_DANGER = 4 +DISCORD_COMPONENT_BUTTON_STYLE_LINK = 5 + +DISCORD_COMPONENT_BUTTON_STYLES = [ + DISCORD_COMPONENT_BUTTON_STYLE_PRIMARY, + DISCORD_COMPONENT_BUTTON_STYLE_SECONDARY, + DISCORD_COMPONENT_BUTTON_STYLE_SUCCESS, + DISCORD_COMPONENT_BUTTON_STYLE_DANGER, + DISCORD_COMPONENT_BUTTON_STYLE_LINK, +] + +DISCORD_COMPONENT_TEXT_INPUT_STYLE_SHORT = 1 +DISCORD_COMPONENT_TEXT_INPUT_STYLE_PARAGRAPH = 2 + +DISCORD_COMPONENT_TEXT_INPUT_STYLES = [ + DISCORD_COMPONENT_TEXT_INPUT_STYLE_SHORT, + DISCORD_COMPONENT_TEXT_INPUT_STYLE_PARAGRAPH, +] + +DISCORD_CHANNEL_TYPE_GUILD_TEXT = 0 +DISCORD_CHANNEL_TYPE_DM = 1 +DISCORD_CHANNEL_TYPE_GUILD_VOICE = 2 +DISCORD_CHANNEL_TYPE_GROUP_DM = 3 +DISCORD_CHANNEL_TYPE_GUILD_CATEGORY = 4 +DISCORD_CHANNEL_TYPE_GUILD_ANNOUNCEMENT = 5 +DISCORD_CHANNEL_TYPE_ANNOUNCEMENT_THREAD = 10 +DISCORD_CHANNEL_TYPE_PUBLIC_THREAD = 11 +DISCORD_CHANNEL_TYPE_PRIVATE_THREAD = 12 +DISCORD_CHANNEL_TYPE_GUILD_STAGE_VOICE = 13 +DISCORD_CHANNEL_TYPE_GUILD_DIRECTORY = 14 +DISCORD_CHANNEL_TYPE_GUILD_FORUM = 15 +DISCORD_CHANNEL_TYPE_GUILD_MEDIA = 16 + +DISCORD_CHANNEL_TYPES = [ + DISCORD_CHANNEL_TYPE_GUILD_TEXT, + DISCORD_CHANNEL_TYPE_DM, + DISCORD_CHANNEL_TYPE_GUILD_VOICE, + DISCORD_CHANNEL_TYPE_GROUP_DM, + DISCORD_CHANNEL_TYPE_GUILD_CATEGORY, + DISCORD_CHANNEL_TYPE_GUILD_ANNOUNCEMENT, + DISCORD_CHANNEL_TYPE_ANNOUNCEMENT_THREAD, + DISCORD_CHANNEL_TYPE_PUBLIC_THREAD, + DISCORD_CHANNEL_TYPE_PRIVATE_THREAD, + DISCORD_CHANNEL_TYPE_GUILD_STAGE_VOICE, + DISCORD_CHANNEL_TYPE_GUILD_DIRECTORY, + DISCORD_CHANNEL_TYPE_GUILD_FORUM, + DISCORD_CHANNEL_TYPE_GUILD_MEDIA, +] + +# maximum amount of action rows a message can contain +DISCORD_MAX_ACTION_ROWS = 5 +# maximum amount of buttons a single action row can contain +DISCORD_MAX_BUTTONS_PER_ACTION_ROW = 5 +# maximum amount of options a select menu can contain +DISCORD_MAX_SELECT_OPTIONS = 25 + +DISCORD_POLL_LAYOUT_TYPE_DEFAULT = 1 + +DISCORD_POLL_LAYOUT_TYPES = [ + DISCORD_POLL_LAYOUT_TYPE_DEFAULT, +] + +# maximum amount of answers a poll can contain +DISCORD_POLL_MAX_ANSWERS = 10 +# maximum amount of characters of a poll question +DISCORD_POLL_MAX_QUESTION_LENGTH = 300 +# maximum amount of characters of a poll answer +DISCORD_POLL_MAX_ANSWER_LENGTH = 55 +# hours a poll is open for if no duration is provided +DISCORD_POLL_DEFAULT_DURATION = 24 +# maximum amount of hours a poll can be open for (32 days) +DISCORD_POLL_MAX_DURATION = 768 diff --git a/discord_webhook/poll.py b/discord_webhook/poll.py new file mode 100644 index 0000000..14047dc --- /dev/null +++ b/discord_webhook/poll.py @@ -0,0 +1,165 @@ +from typing import Any + +from . import constants +from .components import DiscordComponentEmoji +from .utils import check_max_length, is_int, to_dict +from .webhook_exceptions import PollException + + +class DiscordPollMedia: + """ + Represent the common object that is used by the question and the answers of a poll. + """ + + emoji: dict | DiscordComponentEmoji | None + text: str | None + + def __init__( + self, + text: str | None = None, + emoji: dict | DiscordComponentEmoji | None = None, + max_text_length: int = constants.DISCORD_POLL_MAX_QUESTION_LENGTH, + ) -> None: + """ + :param str text: text of the media object + :param emoji: emoji of the media object + :param int max_text_length: maximum amount of characters of the text + """ + if text is None and emoji is None: + raise PollException("Either text or emoji needs to be provided.") + + self.text = text + self.emoji = emoji + + check_max_length(self.text, max_text_length, "text", PollException) + + def to_dict(self) -> dict[str, Any]: + """ + Convert the media object to a dict. + :return: media object as dict + """ + return { + key: to_dict(value) + for key, value in self.__dict__.items() + if value is not None + } + + +class DiscordPollAnswer(DiscordPollMedia): + """ + Represent an answer of a poll. + """ + + def __init__( + self, + text: str | None = None, + emoji: dict | DiscordComponentEmoji | None = None, + ) -> None: + """ + :param str text: text of the answer + :param emoji: emoji of the answer + """ + super().__init__( + text=text, + emoji=emoji, + max_text_length=constants.DISCORD_POLL_MAX_ANSWER_LENGTH, + ) + + def to_dict(self) -> dict[str, Any]: + """ + Convert the answer to a dict. + :return: answer as dict + """ + return {"poll_media": super().to_dict()} + + +class DiscordPoll: + """ + Represent a poll object for the use in webhooks. + """ + + allow_multiselect: bool + answers: list[dict | DiscordPollAnswer] + duration: int + layout_type: int + question: dict | DiscordPollMedia | str + + def __init__( + self, + question: dict | DiscordPollMedia | str, + answers: list[dict | DiscordPollAnswer | str] | None = None, + **kwargs, + ) -> None: + """ + :param question: question of the poll (only text is supported by Discord) + :param list answers: answers of the poll (up to 10) + :keyword bool allow_multiselect: whether multiple answers can be selected + :keyword int duration: hours the poll is open for (1 - 768, defaults to 24) + :keyword int layout_type: layout of the poll (defaults to 1) + """ + self.question = ( + DiscordPollMedia(text=question) if isinstance(question, str) else question + ) + self.answers = [] + self.allow_multiselect = kwargs.get("allow_multiselect", False) + self.duration = kwargs.get("duration", constants.DISCORD_POLL_DEFAULT_DURATION) + self.layout_type = kwargs.get( + "layout_type", constants.DISCORD_POLL_LAYOUT_TYPE_DEFAULT + ) + + if ( + not is_int(self.duration) + or not 1 <= self.duration <= constants.DISCORD_POLL_MAX_DURATION + ): + raise PollException( + "duration needs to be an integer between 1 and" + f" {constants.DISCORD_POLL_MAX_DURATION}." + ) + if self.layout_type not in constants.DISCORD_POLL_LAYOUT_TYPES: + raise PollException("The provided layout type is invalid.") + + for answer in answers or []: + self.add_answer(answer) + + def add_answer(self, answer: dict | DiscordPollAnswer | str) -> None: + """ + Add an answer to the poll. + :param answer: answer instance, dict or the text of the answer + """ + if len(self.answers) >= constants.DISCORD_POLL_MAX_ANSWERS: + raise PollException( + f"A poll can contain up to {constants.DISCORD_POLL_MAX_ANSWERS} answers." + ) + self.answers.append( + DiscordPollAnswer(text=answer) if isinstance(answer, str) else answer + ) + + def remove_answer(self, index: int) -> None: + """ + Remove an answer from the already added answers. + :param int index: index of the answer + """ + self.answers.pop(index) + + def get_answers(self) -> list[dict | DiscordPollAnswer]: + """ + Get all answers of the poll as a list. + :return: answers of the poll + """ + return self.answers + + def to_dict(self) -> dict[str, Any]: + """ + Convert the poll to a dict. + :return: poll as dict + """ + if not self.answers: + raise PollException("A poll needs at least one answer.") + + return { + "question": to_dict(self.question), + "answers": to_dict(self.answers), + "allow_multiselect": self.allow_multiselect, + "duration": self.duration, + "layout_type": self.layout_type, + } diff --git a/discord_webhook/utils.py b/discord_webhook/utils.py new file mode 100644 index 0000000..9f80ea6 --- /dev/null +++ b/discord_webhook/utils.py @@ -0,0 +1,39 @@ +from typing import Any + + +def to_dict(value: Any) -> Any: + """ + Convert objects and nested values to JSON serializable data. + :param value: value that should be converted + :return: converted value + """ + if isinstance(value, list): + return [to_dict(item) for item in value] + if isinstance(value, dict): + return {key: to_dict(item) for key, item in value.items() if item is not None} + if hasattr(value, "to_dict"): + return value.to_dict() + return value + + +def is_int(value: Any) -> bool: + """ + Check whether the given value is an integer. Booleans are not accepted. + :param value: value that should be checked + :return: whether the value is an integer + """ + return isinstance(value, int) and not isinstance(value, bool) + + +def check_max_length( + value: Any, max_length: int, name: str, exception: type[Exception] +) -> None: + """ + Raise the given exception if the value exceeds the maximum length. + :param value: value that should be checked + :param int max_length: maximum amount of characters + :param str name: name of the field that is being checked + :param exception: exception that should be raised + """ + if value is not None and len(value) > max_length: + raise exception(f"{name} can be a maximum of {max_length} characters.") diff --git a/discord_webhook/webhook.py b/discord_webhook/webhook.py index 8291814..880c13d 100644 --- a/discord_webhook/webhook.py +++ b/discord_webhook/webhook.py @@ -1,13 +1,17 @@ import json import logging import time -from datetime import datetime, timezone +from datetime import UTC, datetime from functools import partial from http.client import HTTPException -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any + import requests -from .webhook_exceptions import ColorNotInRangeException +from . import constants +from .components import DiscordComponentActionRow +from .poll import DiscordPoll +from .webhook_exceptions import ColorNotInRangeException, ComponentException logger = logging.getLogger(__name__) @@ -17,23 +21,23 @@ class DiscordEmbed: Discord Embed """ - author: Optional[Dict[str, Optional[str]]] - color: Optional[int] - description: Optional[str] - fields: List[Dict[str, Optional[Any]]] - footer: Optional[Dict[str, Optional[str]]] - image: Optional[Dict[str, Optional[Union[str, int]]]] - provider: Optional[Dict[str, Any]] - thumbnail: Optional[Dict[str, Optional[Union[str, int]]]] - timestamp: Optional[str] - title: Optional[str] - url: Optional[str] - video: Optional[Dict[str, Optional[Union[str, int]]]] + author: dict[str, str | None] | None + color: int | None + description: str | None + fields: list[dict[str, Any | None]] + footer: dict[str, str | None] | None + image: dict[str, str | int | None] | None + provider: dict[str, Any] | None + thumbnail: dict[str, str | int | None] | None + timestamp: str | None + title: str | None + url: str | None + video: dict[str, str | int | None] | None def __init__( self, - title: Optional[str] = None, - description: Optional[str] = None, + title: str | None = None, + description: str | None = None, **kwargs: Any, ) -> None: """ @@ -88,25 +92,23 @@ def set_url(self, url: str) -> None: self.url = url def set_timestamp( - self, timestamp: Optional[Union[float, int, str, datetime]] = None + self, timestamp: float | int | str | datetime | None = None ) -> None: """ Set timestamp of the embed content. :param timestamp: optional timestamp of embed content """ if timestamp is None: - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) elif isinstance(timestamp, float) or isinstance(timestamp, int): - timestamp = datetime.fromtimestamp(timestamp, timezone.utc).replace( - tzinfo=None - ) + timestamp = datetime.fromtimestamp(timestamp, UTC).replace(tzinfo=None) if not isinstance(timestamp, str): timestamp = timestamp.isoformat() self.timestamp = timestamp - def set_color(self, color: Union[str, int]) -> None: + def set_color(self, color: str | int) -> None: """ Set the color of the embed. :param color: color code as decimal(int) or hex(string) @@ -128,7 +130,7 @@ def set_footer(self, text: str, **kwargs) -> None: "proxy_icon_url": kwargs.get("proxy_icon_url"), } - def set_image(self, url: str, **kwargs: Union[str, int]) -> None: + def set_image(self, url: str, **kwargs: str | int) -> None: """ Set the image that will be displayed in the embed. :param str url: source url of image (only supports http(s) and attachments) @@ -143,7 +145,7 @@ def set_image(self, url: str, **kwargs: Union[str, int]) -> None: "width": kwargs.get("width"), } - def set_thumbnail(self, url: str, **kwargs: Union[str, int]) -> None: + def set_thumbnail(self, url: str, **kwargs: str | int) -> None: """ Set the thumbnail that will be displayed in the embed. :param str url: source url of thumbnail (only supports http(s) and attachments) @@ -158,7 +160,7 @@ def set_thumbnail(self, url: str, **kwargs: Union[str, int]) -> None: "width": kwargs.get("width"), } - def set_video(self, **kwargs: Union[str, int]) -> None: + def set_video(self, **kwargs: str | int) -> None: """ Set the video that will be displayed in the embed. :keyword str url: source url of video @@ -211,7 +213,7 @@ def delete_embed_field(self, index: int) -> None: """ self.fields.pop(index) - def get_embed_fields(self) -> List[Dict[str, Optional[Any]]]: + def get_embed_fields(self) -> list[dict[str, Any | None]]: """ Get all stored fields of the embed as a list. :return: fields of the embed @@ -224,23 +226,24 @@ class DiscordWebhook: Webhook for Discord """ - allowed_mentions: Dict[str, List[str]] - attachments: Optional[List[Dict[str, Any]]] - avatar_url: Optional[str] - components: Optional[list] - content: Optional[Union[str, bytes]] - embeds: List[Dict[str, Any]] - files: Dict[str, Tuple[Optional[str], Union[bytes, str]]] - id: Optional[str] - proxies: Optional[Dict[str, str]] + allowed_mentions: dict[str, list[str]] + attachments: list[dict[str, Any]] | None + avatar_url: str | None + components: list | None + content: str | bytes | None + embeds: list[dict[str, Any]] + files: dict[str, tuple[str | None, bytes | str]] + id: str | None + proxies: dict[str, str] | None rate_limit_retry: bool = False - thread_id: Optional[str] - thread_name: Optional[str] - timeout: Optional[float] - tts: Optional[bool] + thread_id: str | None + thread_name: str | None + timeout: float | None + tts: bool | None + poll: dict | None url: str - username: Optional[str] - wait: Optional[bool] + username: str | None + wait: bool | None def __init__(self, url: str, **kwargs) -> None: """ @@ -250,11 +253,13 @@ def __init__(self, url: str, **kwargs) -> None: :keyword dict allowed_mentions: allowed mentions for the message :keyword dict attachments: attachments that should be included :keyword str avatar_url: override the default avatar of the webhook + :keyword list components: list of action rows with components :keyword str content: the message contents :keyword list embeds: list of embedded rich content :keyword int flags: apply flags to the message :keyword dict files: to apply file(s) with message :keyword str id: webhook id + :keyword poll: poll that should be sent with the message :keyword dict proxies: proxies that should be used :keyword bool rate_limit_retry: whether the message should be sent again when being rate limited :keyword str thread_id: send message to a thread specified by its thread id @@ -267,11 +272,13 @@ def __init__(self, url: str, **kwargs) -> None: self.allowed_mentions = kwargs.get("allowed_mentions", {}) self.attachments = kwargs.get("attachments", []) self.avatar_url = kwargs.get("avatar_url") + self.components = [] self.content = kwargs.get("content") self.embeds = kwargs.get("embeds", []) self.flags = kwargs.get("flags") self.files = kwargs.get("files", {}) self.id = kwargs.get("id") + self.poll = None self.proxies = kwargs.get("proxies") self.rate_limit_retry = kwargs.get("rate_limit_retry", False) self.thread_id = kwargs.get("thread_id") @@ -281,15 +288,70 @@ def __init__(self, url: str, **kwargs) -> None: self.url = url self.username = kwargs.get("username", False) self.wait = kwargs.get("wait", True) + for action_row in kwargs.get("components", []): + self.add_component_row(action_row) + if poll := kwargs.get("poll"): + self.set_poll(poll) + + def set_poll(self, poll: DiscordPoll | dict[str, Any]) -> None: + """ + Set the poll of the webhook. + :param poll: poll instance or dict + """ + self.poll = poll.to_dict() if isinstance(poll, DiscordPoll) else poll + + def remove_poll(self) -> None: + """ + Remove the poll of the webhook. + """ + self.poll = None + + def add_component_row( + self, action_row: DiscordComponentActionRow | dict[str, Any] + ) -> None: + """ + Add an action row with components to the webhook. + :param action_row: action row instance or dict + """ + if len(self.components) >= constants.DISCORD_MAX_ACTION_ROWS: + raise ComponentException( + f"A message can contain up to {constants.DISCORD_MAX_ACTION_ROWS} action" + " rows." + ) + self.components.append( + action_row.to_dict() + if isinstance(action_row, DiscordComponentActionRow) + else action_row + ) - def add_embed(self, embed: Union[DiscordEmbed, Dict[str, Any]]) -> None: + def get_component_rows(self) -> list[dict[str, Any]]: + """ + Get all action rows as a list. + :return: action rows + """ + return self.components + + def remove_component_row(self, index: int) -> None: + """ + Remove an action row from the already added action rows. + :param int index: index of the action row + """ + self.components.pop(index) + + def remove_component_rows(self) -> None: + """ + Remove all action rows. + """ + self.components = [] + + def add_embed(self, embed: DiscordEmbed | dict[str, Any]) -> None: """ Add an embedded rich content. :param embed: embed object or dict """ self.embeds.append(embed.__dict__ if isinstance(embed, DiscordEmbed) else embed) - def get_embeds(self) -> List[Dict[str, Any]]: + def get_embeds(self) -> list[dict[str, Any]]: """ Get all embeds as a list. :return: embeds @@ -350,7 +412,7 @@ def clear_attachments(self) -> None: """ self.attachments = [] - def set_proxies(self, proxies: Dict[str, str]) -> None: + def set_proxies(self, proxies: dict[str, str]) -> None: """ Set proxies that should be used when sending the webhook. :param dict proxies: dict of proxies @@ -372,7 +434,7 @@ def set_flags(self, flags: int) -> None: self.flags = flags @property - def json(self) -> Dict[str, Any]: + def json(self) -> dict[str, Any]: """ Convert data of the webhook to JSON. :return: webhook data as json @@ -388,7 +450,12 @@ def json(self) -> Dict[str, Any]: if value and key not in ["url", "files"] or key in ["embeds", "attachments"] } embeds_empty = not any(data["embeds"]) if "embeds" in data else True - if embeds_empty and "content" not in data and bool(self.files) is False: + if ( + embeds_empty + and "content" not in data + and bool(self.files) is False + and not self.poll + ): logger.error("webhook message is empty! set content or embed data") return data @@ -482,12 +549,12 @@ def edit(self) -> "requests.Response": Edit an already sent webhook with updated data. :return: Response of the sent webhook """ - assert isinstance( - self.id, str - ), "Webhook ID needs to be set in order to edit the webhook." - assert isinstance( - self.url, str - ), "Webhook URL needs to be set in order to edit the webhook." + assert isinstance(self.id, str), ( + "Webhook ID needs to be set in order to edit the webhook." + ) + assert isinstance(self.url, str), ( + "Webhook URL needs to be set in order to edit the webhook." + ) url = f"{self.url}/messages/{self.id}" if bool(self.files) is False: request = partial( @@ -509,7 +576,7 @@ def edit(self) -> "requests.Response": ) response = request() if response.status_code in [200, 204]: - logger.debug("Webhook with id {id} edited".format(id=self.id)) + logger.debug(f"Webhook with id {self.id} edited") elif response.status_code == 429 and self.rate_limit_retry: response = self.handle_rate_limit(response, request) logger.debug("Webhook edited") @@ -527,12 +594,12 @@ def delete(self) -> "requests.Response": Delete the already sent webhook. :return: webhook response """ - assert isinstance( - self.id, str - ), "Webhook ID needs to be set in order to delete the webhook." - assert isinstance( - self.url, str - ), "Webhook URL needs to be set in order to delete the webhook." + assert isinstance(self.id, str), ( + "Webhook ID needs to be set in order to delete the webhook." + ) + assert isinstance(self.url, str), ( + "Webhook URL needs to be set in order to delete the webhook." + ) url = f"{self.url}/messages/{self.id}" request = partial( requests.delete, @@ -550,7 +617,7 @@ def delete(self) -> "requests.Response": return response @classmethod - def create_batch(cls, urls: List[str], **kwargs) -> Tuple["DiscordWebhook", ...]: + def create_batch(cls, urls: list[str], **kwargs) -> tuple["DiscordWebhook", ...]: """ Create a webhook instance for each specified URL. :param list urls: webhook URLs to be used for the instances diff --git a/discord_webhook/webhook_exceptions.py b/discord_webhook/webhook_exceptions.py index 6342cf1..bdf0b8d 100644 --- a/discord_webhook/webhook_exceptions.py +++ b/discord_webhook/webhook_exceptions.py @@ -1,6 +1,3 @@ -from typing import Union - - class ColorNotInRangeException(Exception): """ This Exception will be raised when a color is not in that range. @@ -8,7 +5,7 @@ class ColorNotInRangeException(Exception): A valid color must take an integer value between 0 and 16777216 inclusive """ - def __init__(self, color: Union[str, int], message=None) -> None: + def __init__(self, color: str | int, message=None) -> None: if not message: message = ( f"{color!r} is not in valid range of colors. The valid ranges of colors" @@ -16,3 +13,19 @@ def __init__(self, color: Union[str, int], message=None) -> None: " (HEXADECIMAL)." ) super().__init__(message) + + +class ComponentException(Exception): + """ + This Exception will be raised for components. + """ + + pass + + +class PollException(Exception): + """ + This Exception will be raised for polls. + """ + + pass diff --git a/examples/components.py b/examples/components.py new file mode 100644 index 0000000..7d5d92c --- /dev/null +++ b/examples/components.py @@ -0,0 +1,52 @@ +from discord_webhook import ( + DiscordComponentActionRow, + DiscordComponentButton, + DiscordComponentChannelSelect, + DiscordComponentEmoji, + DiscordComponentStringSelect, + DiscordSelectOption, + DiscordWebhook, + constants, +) + +webhook = DiscordWebhook(url="your webhook url", content="Webhook Message") + +# a row with two buttons +buttons = DiscordComponentActionRow( + components=[ + DiscordComponentButton( + style=constants.DISCORD_COMPONENT_BUTTON_STYLE_LINK, + label="Documentation", + url="https://github.com/lovvskillz/python-discord-webhook", + ), + DiscordComponentButton( + style=constants.DISCORD_COMPONENT_BUTTON_STYLE_DANGER, + custom_id="delete_message", + label="Delete", + emoji=DiscordComponentEmoji(name="🗑️"), + ), + ] +) +webhook.add_component_row(buttons) + +# a select menu needs its own action row +select = DiscordComponentStringSelect( + custom_id="favourite_color", + placeholder="Choose your favourite color", + options=[ + DiscordSelectOption(label="Red", value="red", description="like a tomato"), + DiscordSelectOption(label="Blue", value="blue", default=True), + ], +) +webhook.add_component_row(DiscordComponentActionRow(components=[select])) + +# select menus that are populated by Discord itself +channel_select = DiscordComponentChannelSelect( + custom_id="channel", + placeholder="Choose a channel", + channel_types=[constants.DISCORD_CHANNEL_TYPE_GUILD_TEXT], + max_values=3, +) +webhook.add_component_row(DiscordComponentActionRow(components=[channel_select])) + +response = webhook.execute() diff --git a/examples/poll.py b/examples/poll.py new file mode 100644 index 0000000..23ace39 --- /dev/null +++ b/examples/poll.py @@ -0,0 +1,28 @@ +from discord_webhook import ( + DiscordComponentEmoji, + DiscordPoll, + DiscordPollAnswer, + DiscordWebhook, +) + +# a simple poll with two answers that is open for 24 hours +poll = DiscordPoll(question="Your favourite color?", answers=["Red", "Blue"]) + +webhook = DiscordWebhook(url="your webhook url", poll=poll) +response = webhook.execute() + +# a poll with emojis, multiple answers and a custom duration +poll = DiscordPoll( + question="Which languages do you use?", + answers=[ + DiscordPollAnswer(text="Python", emoji=DiscordComponentEmoji(name="🐍")), + DiscordPollAnswer(text="Rust", emoji=DiscordComponentEmoji(name="🦀")), + ], + allow_multiselect=True, + duration=72, +) +poll.add_answer("Something else") + +webhook = DiscordWebhook(url="your webhook url", content="Please vote!") +webhook.set_poll(poll) +response = webhook.execute() diff --git a/img/basic_webhook.png b/img/basic_webhook.png index d684c8c..491f91e 100644 Binary files a/img/basic_webhook.png and b/img/basic_webhook.png differ diff --git a/img/extended_embed.png b/img/extended_embed.png index 56f25dc..7c69255 100644 Binary files a/img/extended_embed.png and b/img/extended_embed.png differ diff --git a/img/extended_embed2.png b/img/extended_embed2.png index 493940f..c9fb641 100644 Binary files a/img/extended_embed2.png and b/img/extended_embed2.png differ diff --git a/img/extended_embed3.png b/img/extended_embed3.png index 3b0d696..c7abca0 100644 Binary files a/img/extended_embed3.png and b/img/extended_embed3.png differ diff --git a/img/multiple_urls.png b/img/multiple_urls.png index 8d650ec..aa8f90b 100644 Binary files a/img/multiple_urls.png and b/img/multiple_urls.png differ diff --git a/img/simple_embed.png b/img/simple_embed.png index 2293825..d3b63da 100644 Binary files a/img/simple_embed.png and b/img/simple_embed.png differ diff --git a/img/webhook_files.png b/img/webhook_files.png index 6c7df23..44bb1f5 100644 Binary files a/img/webhook_files.png and b/img/webhook_files.png differ diff --git a/pyproject.toml b/pyproject.toml index 333939b..6add164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,36 +1,49 @@ -[tool.poetry] +[project] name = "discord-webhook" version = "1.4.1" description = "Easily send Discord webhooks with Python" -authors = ["lovvskillz <14542790+lovvskillz@users.noreply.github.com>"] +authors = [ + { name = "lovvskillz", email = "14542790+lovvskillz@users.noreply.github.com" }, +] license = "MIT" readme = "README.md" -packages = [{include = "discord_webhook"}] -repository = "https://github.com/lovvskillz/python-discord-webhook" +requires-python = ">=3.12" keywords = ["discord", "webhook"] +dependencies = [ + "requests>=2.32.3,<3", +] +[project.optional-dependencies] +async = ["httpx>=0.28.1,<1"] -[tool.poetry.dependencies] -python = "^3.10" -requests = "^2.32.3" -httpx = { version = "^0.28.1", optional = true } +[project.urls] +Repository = "https://github.com/lovvskillz/python-discord-webhook" -[tool.poetry.extras] -async = ["httpx"] +[project.scripts] +discord_webhook = "discord_webhook.__main__:main" -[tool.poetry.group.dev.dependencies] -pre-commit = "^3.3.3" -types-requests = "^2.28.11.4" -pytest = "^7.4.0" -ruff = "^0.1.15" +[dependency-groups] +dev = [ + "pre-commit>=4.6.2", + "types-requests>=2.33.0.20260712", + "pytest>=9.1.1", + "ruff==0.16.3", +] -[tool.poetry.scripts] -discord_webhook = "discord_webhook.__main__:main" +[tool.hatch.build.targets.wheel] +packages = ["discord_webhook"] [tool.ruff] line-length = 88 + +[tool.ruff.format] +# newer ruff also formats code blocks in markdown; keep docs as written +exclude = ["*.md"] + +[tool.ruff.lint] +select = ["E", "F", "UP"] ignore = ["E501"] [build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/components/__init__.py b/tests/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/test_action_row.py b/tests/components/test_action_row.py new file mode 100644 index 0000000..7697da5 --- /dev/null +++ b/tests/components/test_action_row.py @@ -0,0 +1,96 @@ +import pytest +from pytest import mark + +from discord_webhook import ( + DiscordComponentActionRow, + DiscordComponentButton, + DiscordComponentStringSelect, + DiscordComponentTextInput, + DiscordSelectOption, + constants, +) +from discord_webhook.webhook_exceptions import ComponentException + + +def test__action_row_in_action_row(): + action_row_1 = DiscordComponentActionRow() + action_row_2 = DiscordComponentActionRow() + + with pytest.raises(ComponentException) as excinfo: + action_row_1.add_component(action_row_2) + + assert str(excinfo.value) == "An action row can't contain another action row." + assert len(action_row_1.components) == 0 + + +def test__max_buttons(): + action_row = DiscordComponentActionRow() + button = DiscordComponentButton(custom_id="test") + for _ in range(0, 5): + action_row.add_component(button) + + with pytest.raises(ComponentException) as excinfo: + action_row.add_component(button) + + assert str(excinfo.value) == "An Action Row can contain up to 5 buttons." + assert len(action_row.components) == 5 + + +def _exclusive_components(): + return [ + DiscordComponentStringSelect( + custom_id="select", options=[DiscordSelectOption(label="a", value="a")] + ), + DiscordComponentTextInput(custom_id="input", label="test"), + ] + + +@mark.parametrize("component", _exclusive_components()) +def test__exclusive_component__with_existing_button(component): + action_row = DiscordComponentActionRow( + components=[DiscordComponentButton(custom_id="test")] + ) + + with pytest.raises(ComponentException) as excinfo: + action_row.add_component(component) + + assert ( + str(excinfo.value) + == "A select menu or text input has to be the only component in an action row." + ) + assert len(action_row.components) == 1 + + +@mark.parametrize("component", _exclusive_components()) +def test__exclusive_component__followed_by_button(component): + action_row = DiscordComponentActionRow(components=[component]) + + with pytest.raises(ComponentException) as excinfo: + action_row.add_component(DiscordComponentButton(custom_id="test")) + + assert ( + str(excinfo.value) + == "A select menu or text input has to be the only component in an action row." + ) + assert len(action_row.components) == 1 + + +def test__action_row__to_dict(): + action_row = DiscordComponentActionRow( + components=[ + DiscordComponentButton(custom_id="test", label="Click me", disabled=True) + ] + ) + + assert action_row.to_dict() == { + "type": constants.DISCORD_COMPONENT_TYPE_ACTION_ROW, + "components": [ + { + "type": constants.DISCORD_COMPONENT_TYPE_BUTTON, + "style": constants.DISCORD_COMPONENT_BUTTON_STYLE_PRIMARY, + "custom_id": "test", + "label": "Click me", + "disabled": True, + } + ], + } diff --git a/tests/components/test_button.py b/tests/components/test_button.py new file mode 100644 index 0000000..acf98d4 --- /dev/null +++ b/tests/components/test_button.py @@ -0,0 +1,58 @@ +import pytest +from pytest import mark + +from discord_webhook import DiscordComponentButton, constants +from discord_webhook.webhook_exceptions import ComponentException + + +@mark.parametrize( + "style, field, error_message", + [ + ( + constants.DISCORD_COMPONENT_BUTTON_STYLE_PRIMARY, + "custom_id", + "custom_id needs to be provided as a kwarg.", + ), + ( + constants.DISCORD_COMPONENT_BUTTON_STYLE_SECONDARY, + "custom_id", + "custom_id needs to be provided as a kwarg.", + ), + ( + constants.DISCORD_COMPONENT_BUTTON_STYLE_SUCCESS, + "custom_id", + "custom_id needs to be provided as a kwarg.", + ), + ( + constants.DISCORD_COMPONENT_BUTTON_STYLE_DANGER, + "custom_id", + "custom_id needs to be provided as a kwarg.", + ), + ( + constants.DISCORD_COMPONENT_BUTTON_STYLE_LINK, + "url", + "url needs to be provided as a kwarg.", + ), + ], +) +def test__styles__required_fields(style, field, error_message): + # valid button + DiscordComponentButton(**{"style": style, field: "test_string"}) + + # required field is missing + with pytest.raises(ComponentException) as excinfo: + DiscordComponentButton(style=style) + + assert str(excinfo.value) == error_message + + +@mark.parametrize("invalid_style", [0, 6, "a", True, None]) +def test__styles__invalid(invalid_style): + with pytest.raises(ComponentException) as excinfo: + DiscordComponentButton(style=invalid_style) + + assert ( + str(excinfo.value) + == "The provided button style is invalid. A valid button style is an integer" + " between 1 and 5." + ) diff --git a/tests/components/test_component.py b/tests/components/test_component.py new file mode 100644 index 0000000..9193a89 --- /dev/null +++ b/tests/components/test_component.py @@ -0,0 +1,43 @@ +import pytest + +from discord_webhook.components import BaseDiscordComponent +from discord_webhook import constants +from discord_webhook.webhook_exceptions import ComponentException + + +def test__component__types(): + for component_type in constants.DISCORD_COMPONENT_TYPES: + + class TestDiscordComponent(BaseDiscordComponent): + type = component_type + + TestDiscordComponent() + + for component_type in [0, 9, "a", True]: + + class TestDiscordComponent(BaseDiscordComponent): + type = component_type + + with pytest.raises(ComponentException) as excinfo: + TestDiscordComponent() + + assert ( + str(excinfo.value) + == "The provided component type is invalid. A valid component type is an" + " integer between 1 and 8." + ) + + +def test__component__custom_id_max_length(): + class TestDiscordComponent(BaseDiscordComponent): + type = constants.DISCORD_COMPONENT_TYPE_BUTTON + + custom_id = "".join("a" for i in range(100)) + + TestDiscordComponent(custom_id=custom_id) + + # total length of 101 chars + with pytest.raises(ComponentException) as excinfo: + TestDiscordComponent(custom_id=f"{custom_id}a") + + assert str(excinfo.value) == "custom_id can be a maximum of 100 characters." diff --git a/tests/components/test_emoji.py b/tests/components/test_emoji.py new file mode 100644 index 0000000..e0356e5 --- /dev/null +++ b/tests/components/test_emoji.py @@ -0,0 +1,34 @@ +import pytest + +from discord_webhook import DiscordComponentButton, DiscordComponentEmoji +from discord_webhook.webhook_exceptions import ComponentException + + +def test__emoji__unicode(): + assert DiscordComponentEmoji(name="🔥").to_dict() == { + "name": "🔥", + "animated": False, + } + + +def test__emoji__custom(): + assert DiscordComponentEmoji(name="blob", id="123", animated=True).to_dict() == { + "name": "blob", + "id": "123", + "animated": True, + } + + +def test__emoji__missing_name_and_id(): + with pytest.raises(ComponentException) as excinfo: + DiscordComponentEmoji() + + assert str(excinfo.value) == "Either name or id needs to be provided." + + +def test__emoji__in_button(): + button = DiscordComponentButton( + custom_id="test", label="fire", emoji=DiscordComponentEmoji(name="🔥") + ) + + assert button.to_dict()["emoji"] == {"name": "🔥", "animated": False} diff --git a/tests/components/test_select.py b/tests/components/test_select.py new file mode 100644 index 0000000..9459d12 --- /dev/null +++ b/tests/components/test_select.py @@ -0,0 +1,152 @@ +import pytest +from pytest import mark + +from discord_webhook import ( + DiscordComponentChannelSelect, + DiscordComponentMentionableSelect, + DiscordComponentRoleSelect, + DiscordComponentStringSelect, + DiscordComponentUserSelect, + DiscordSelectOption, + constants, +) +from discord_webhook.webhook_exceptions import ComponentException + +AUTO_POPULATED_SELECTS = [ + (DiscordComponentUserSelect, constants.DISCORD_COMPONENT_TYPE_USER_SELECT, "user"), + (DiscordComponentRoleSelect, constants.DISCORD_COMPONENT_TYPE_ROLE_SELECT, "role"), + ( + DiscordComponentMentionableSelect, + constants.DISCORD_COMPONENT_TYPE_MENTIONABLE_SELECT, + "user", + ), + ( + DiscordComponentChannelSelect, + constants.DISCORD_COMPONENT_TYPE_CHANNEL_SELECT, + "channel", + ), +] + + +@mark.parametrize("select_class, component_type, _", AUTO_POPULATED_SELECTS) +def test__auto_populated_selects__type(select_class, component_type, _): + assert select_class(custom_id="test").to_dict()["type"] == component_type + + +@mark.parametrize("select_class, _, value_type", AUTO_POPULATED_SELECTS) +def test__auto_populated_selects__default_values(select_class, _, value_type): + select = select_class(custom_id="test") + select.add_default_value(123456789) + + assert select.to_dict()["default_values"] == [ + {"id": "123456789", "type": value_type} + ] + + +def test__default_values__invalid_type(): + select = DiscordComponentUserSelect(custom_id="test") + + with pytest.raises(ComponentException) as excinfo: + select.add_default_value(123456789, value_type="invalid") + + assert ( + str(excinfo.value) + == 'The type of a default value needs to be "user", "role" or "channel".' + ) + + +def test__string_select__options(): + select = DiscordComponentStringSelect( + custom_id="test", + options=[DiscordSelectOption(label="first", value="1")], + placeholder="choose", + ) + select.add_option({"label": "second", "value": "2"}) + + assert select.to_dict() == { + "type": constants.DISCORD_COMPONENT_TYPE_STRING_SELECT, + "custom_id": "test", + "placeholder": "choose", + "disabled": False, + "min_values": 1, + "max_values": 1, + "options": [ + {"label": "first", "value": "1", "default": False}, + {"label": "second", "value": "2"}, + ], + } + + +def test__string_select__max_options(): + select = DiscordComponentStringSelect(custom_id="test") + for index in range(constants.DISCORD_MAX_SELECT_OPTIONS): + select.add_option(DiscordSelectOption(label=str(index), value=str(index))) + + with pytest.raises(ComponentException) as excinfo: + select.add_option(DiscordSelectOption(label="too much", value="26")) + + assert str(excinfo.value) == "A select menu can contain up to 25 options." + assert len(select.options) == constants.DISCORD_MAX_SELECT_OPTIONS + + +@mark.parametrize( + "kwargs, error_message", + [ + ({"min_values": 26}, "min_values needs to be an integer between 0 and 25."), + ({"min_values": -1}, "min_values needs to be an integer between 0 and 25."), + ({"max_values": 0}, "max_values needs to be an integer between 1 and 25."), + ({"max_values": "a"}, "max_values needs to be an integer between 1 and 25."), + ( + {"placeholder": "a" * 151}, + "placeholder can be a maximum of 150 characters.", + ), + ], +) +def test__select__invalid_values(kwargs, error_message): + with pytest.raises(ComponentException) as excinfo: + DiscordComponentStringSelect(custom_id="test", **kwargs) + + assert str(excinfo.value) == error_message + + +def test__select__missing_custom_id(): + with pytest.raises(ComponentException) as excinfo: + DiscordComponentStringSelect(custom_id="") + + assert str(excinfo.value) == "custom_id needs to be provided." + + +def test__channel_select__channel_types(): + select = DiscordComponentChannelSelect( + custom_id="test", channel_types=[constants.DISCORD_CHANNEL_TYPE_GUILD_TEXT] + ) + + assert select.to_dict()["channel_types"] == [ + constants.DISCORD_CHANNEL_TYPE_GUILD_TEXT + ] + + with pytest.raises(ComponentException) as excinfo: + DiscordComponentChannelSelect(custom_id="test", channel_types=[99]) + + assert str(excinfo.value) == "99 is not a valid channel type." + + +@mark.parametrize( + "kwargs, error_message", + [ + ({"label": "a" * 101}, "label can be a maximum of 100 characters."), + ({"value": "a" * 101}, "value can be a maximum of 100 characters."), + ( + {"description": "a" * 101}, + "description can be a maximum of 100 characters.", + ), + ], +) +def test__select_option__max_lengths(kwargs, error_message): + option_kwargs = {"label": "test", "value": "test"} + option_kwargs.update(kwargs) + + with pytest.raises(ComponentException) as excinfo: + DiscordSelectOption(**option_kwargs) + + assert str(excinfo.value) == error_message diff --git a/tests/components/test_text_input.py b/tests/components/test_text_input.py new file mode 100644 index 0000000..eff5551 --- /dev/null +++ b/tests/components/test_text_input.py @@ -0,0 +1,69 @@ +import pytest +from pytest import mark + +from discord_webhook import DiscordComponentTextInput, constants +from discord_webhook.webhook_exceptions import ComponentException + + +def test__text_input__defaults(): + text_input = DiscordComponentTextInput(custom_id="test", label="Your name") + + assert text_input.to_dict() == { + "type": constants.DISCORD_COMPONENT_TYPE_TEXT_INPUT, + "custom_id": "test", + "label": "Your name", + "style": constants.DISCORD_COMPONENT_TEXT_INPUT_STYLE_SHORT, + "required": True, + } + + +def test__text_input__paragraph(): + text_input = DiscordComponentTextInput( + custom_id="test", + label="Your feedback", + style=constants.DISCORD_COMPONENT_TEXT_INPUT_STYLE_PARAGRAPH, + placeholder="Tell us more", + min_length=10, + max_length=1000, + required=False, + ) + data = text_input.to_dict() + + assert data["style"] == constants.DISCORD_COMPONENT_TEXT_INPUT_STYLE_PARAGRAPH + assert data["min_length"] == 10 + assert data["max_length"] == 1000 + assert data["placeholder"] == "Tell us more" + assert data["required"] is False + + +@mark.parametrize("invalid_style", [0, 3, "a", True, None]) +def test__text_input__invalid_style(invalid_style): + with pytest.raises(ComponentException) as excinfo: + DiscordComponentTextInput(custom_id="test", label="test", style=invalid_style) + + assert ( + str(excinfo.value) + == "The provided text input style is invalid. A valid text input style is an" + " integer between 1 and 2." + ) + + +@mark.parametrize( + "kwargs, error_message", + [ + ({"label": "a" * 46}, "label can be a maximum of 45 characters."), + ( + {"placeholder": "a" * 101}, + "placeholder can be a maximum of 100 characters.", + ), + ({"value": "a" * 4001}, "value can be a maximum of 4000 characters."), + ], +) +def test__text_input__max_lengths(kwargs, error_message): + input_kwargs = {"custom_id": "test", "label": "test"} + input_kwargs.update(kwargs) + + with pytest.raises(ComponentException) as excinfo: + DiscordComponentTextInput(**input_kwargs) + + assert str(excinfo.value) == error_message diff --git a/tests/components/test_webhook_components.py b/tests/components/test_webhook_components.py new file mode 100644 index 0000000..533d9c6 --- /dev/null +++ b/tests/components/test_webhook_components.py @@ -0,0 +1,82 @@ +import pytest + +from discord_webhook import ( + DiscordComponentActionRow, + DiscordComponentButton, + DiscordWebhook, + constants, +) +from discord_webhook.webhook_exceptions import ComponentException + + +def _action_row(): + return DiscordComponentActionRow( + components=[ + DiscordComponentButton( + style=constants.DISCORD_COMPONENT_BUTTON_STYLE_LINK, + label="GitHub", + url="https://github.com/lovvskillz/python-discord-webhook", + ) + ] + ) + + +def test__webhook__add_component_row(): + webhook = DiscordWebhook(url="webhook_url", content="test") + webhook.add_component_row(_action_row()) + + assert webhook.json["components"] == [ + { + "type": constants.DISCORD_COMPONENT_TYPE_ACTION_ROW, + "components": [ + { + "type": constants.DISCORD_COMPONENT_TYPE_BUTTON, + "style": constants.DISCORD_COMPONENT_BUTTON_STYLE_LINK, + "label": "GitHub", + "url": "https://github.com/lovvskillz/python-discord-webhook", + "disabled": False, + } + ], + } + ] + + +def test__webhook__components_kwarg(): + webhook = DiscordWebhook( + url="webhook_url", content="test", components=[_action_row()] + ) + + assert len(webhook.get_component_rows()) == 1 + + +def test__webhook__components_are_not_shared_between_instances(): + webhook_1 = DiscordWebhook(url="webhook_url", content="test") + webhook_1.add_component_row(_action_row()) + webhook_2 = DiscordWebhook(url="webhook_url", content="test") + + assert webhook_2.get_component_rows() == [] + + +def test__webhook__remove_component_rows(): + webhook = DiscordWebhook(url="webhook_url", content="test") + webhook.add_component_row(_action_row()) + webhook.add_component_row(_action_row()) + + webhook.remove_component_row(0) + assert len(webhook.get_component_rows()) == 1 + + webhook.remove_component_rows() + assert webhook.get_component_rows() == [] + assert "components" not in webhook.json + + +def test__webhook__max_action_rows(): + webhook = DiscordWebhook(url="webhook_url", content="test") + for _ in range(constants.DISCORD_MAX_ACTION_ROWS): + webhook.add_component_row(_action_row()) + + with pytest.raises(ComponentException) as excinfo: + webhook.add_component_row(_action_row()) + + assert str(excinfo.value) == "A message can contain up to 5 action rows." + assert len(webhook.get_component_rows()) == constants.DISCORD_MAX_ACTION_ROWS diff --git a/tests/test_poll.py b/tests/test_poll.py new file mode 100644 index 0000000..695e4ab --- /dev/null +++ b/tests/test_poll.py @@ -0,0 +1,136 @@ +import pytest +from pytest import mark + +from discord_webhook import ( + DiscordComponentEmoji, + DiscordPoll, + DiscordPollAnswer, + DiscordPollMedia, + DiscordWebhook, + constants, +) +from discord_webhook.webhook_exceptions import PollException + + +def test__poll__defaults(): + poll = DiscordPoll(question="Your favourite color?", answers=["Red", "Blue"]) + + assert poll.to_dict() == { + "question": {"text": "Your favourite color?"}, + "answers": [ + {"poll_media": {"text": "Red"}}, + {"poll_media": {"text": "Blue"}}, + ], + "allow_multiselect": False, + "duration": constants.DISCORD_POLL_DEFAULT_DURATION, + "layout_type": constants.DISCORD_POLL_LAYOUT_TYPE_DEFAULT, + } + + +def test__poll__custom_values(): + poll = DiscordPoll( + question=DiscordPollMedia(text="Your favourite color?"), + allow_multiselect=True, + duration=48, + ) + poll.add_answer( + DiscordPollAnswer(text="Red", emoji=DiscordComponentEmoji(name="🔴")) + ) + poll.add_answer({"poll_media": {"text": "Blue"}}) + + assert poll.to_dict() == { + "question": {"text": "Your favourite color?"}, + "answers": [ + {"poll_media": {"text": "Red", "emoji": {"name": "🔴", "animated": False}}}, + {"poll_media": {"text": "Blue"}}, + ], + "allow_multiselect": True, + "duration": 48, + "layout_type": constants.DISCORD_POLL_LAYOUT_TYPE_DEFAULT, + } + + +def test__poll__answer_without_text_and_emoji(): + with pytest.raises(PollException) as excinfo: + DiscordPollAnswer() + + assert str(excinfo.value) == "Either text or emoji needs to be provided." + + +def test__poll__max_answers(): + poll = DiscordPoll( + question="test", + answers=[str(index) for index in range(constants.DISCORD_POLL_MAX_ANSWERS)], + ) + + with pytest.raises(PollException) as excinfo: + poll.add_answer("one too many") + + assert str(excinfo.value) == "A poll can contain up to 10 answers." + assert len(poll.get_answers()) == constants.DISCORD_POLL_MAX_ANSWERS + + +def test__poll__remove_answer(): + poll = DiscordPoll(question="test", answers=["Red", "Blue"]) + poll.remove_answer(0) + + assert poll.to_dict()["answers"] == [{"poll_media": {"text": "Blue"}}] + + +def test__poll__without_answers(): + poll = DiscordPoll(question="test") + + with pytest.raises(PollException) as excinfo: + poll.to_dict() + + assert str(excinfo.value) == "A poll needs at least one answer." + + +@mark.parametrize("invalid_duration", [0, 769, "a", True, None]) +def test__poll__invalid_duration(invalid_duration): + with pytest.raises(PollException) as excinfo: + DiscordPoll(question="test", duration=invalid_duration) + + assert str(excinfo.value) == "duration needs to be an integer between 1 and 768." + + +def test__poll__invalid_layout_type(): + with pytest.raises(PollException) as excinfo: + DiscordPoll(question="test", layout_type=2) + + assert str(excinfo.value) == "The provided layout type is invalid." + + +@mark.parametrize( + "poll_media_class, max_length", + [ + (DiscordPollMedia, constants.DISCORD_POLL_MAX_QUESTION_LENGTH), + (DiscordPollAnswer, constants.DISCORD_POLL_MAX_ANSWER_LENGTH), + ], +) +def test__poll__max_text_length(poll_media_class, max_length): + poll_media_class(text="a" * max_length) + + with pytest.raises(PollException) as excinfo: + poll_media_class(text="a" * (max_length + 1)) + + assert str(excinfo.value) == f"text can be a maximum of {max_length} characters." + + +def test__webhook__poll(): + webhook = DiscordWebhook( + url="webhook_url", + poll=DiscordPoll(question="Your favourite color?", answers=["Red", "Blue"]), + ) + + assert webhook.json["poll"]["question"] == {"text": "Your favourite color?"} + + webhook.remove_poll() + assert "poll" not in webhook.json + + +def test__webhook__set_poll(): + webhook = DiscordWebhook(url="webhook_url") + webhook.set_poll({"question": {"text": "test"}}) + + assert webhook.json["poll"] == {"question": {"text": "test"}} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f7fce53 --- /dev/null +++ b/uv.lock @@ -0,0 +1,512 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "discord-webhook" +version = "1.4.1" +source = { editable = "." } +dependencies = [ + { name = "requests" }, +] + +[package.optional-dependencies] +async = [ + { name = "httpx" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", marker = "extra == 'async'", specifier = ">=0.28.1,<1" }, + { name = "requests", specifier = ">=2.32.3,<3" }, +] +provides-extras = ["async"] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=4.6.2" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = "==0.16.3" }, + { name = "types-requests", specifier = ">=2.33.0.20260712" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +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/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/b7/ac44da2cf0e53ada0e419033c2d058219c95dc1403126f163304c9e814b1/python_discovery-1.5.2.tar.gz", hash = "sha256:45fd4f20a4e3f9b7bf2e0817870bc8e3b320a19658da177af800768c82dbf354", size = 82350, upload-time = "2026-08-12T14:05:26.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl", hash = "sha256:3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3", size = 38350, upload-time = "2026-08-12T14:05:25.113Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +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 = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260712" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +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 = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/a6eb1ddfa7f1e390fa599b078453c97edb3f6f846b34fb4eac3e8ea16401/virtualenv-21.7.4.tar.gz", hash = "sha256:c9d960c95fa458171e58222a5ccab7465298e4b6559977865e627c4719f1e825", size = 5345511, upload-time = "2026-08-10T22:54:33.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl", hash = "sha256:376ec93cd6aab3044fa395d7db226db38043b7b5748948044b2a87168525e843", size = 5324444, upload-time = "2026-08-10T22:54:31.515Z" }, +]